Sei sulla pagina 1di 373

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.1 Indecidibilit di problemi computazionali 3
1.2 Trattabilit di problemi computazionali 6
1.2.1 Rappresentazione e dimensione dei dati 10
1.2.2 Algoritmi polinomiali ed esponenziali 11
1.3 Problemi NP-completi 14
1.4 Modello RAM e complessit computazionale 18

2 Sequenze: array 23
2.1 Sequenze lineari 23
2.1.1 Modalit di accesso 24
2.1.2 Allocazione della memoria 25
2.1.3 Array di dimensione variabile 27
2.2 Opus libri: scheduling della CPU 28
2.2.1 Ordinamento per selezione 30
2.2.2 Ordinamento per inserimento 31
2.3 Complessit di problemi computazionali 32
2.3.1 Limiti superiori e inferiori 36
2.4 Ricerca di una chiave 37
2.4.1 Ricerca binaria 37
2.4.2 Complessit della ricerca per confronti 40
2.5 Ricorsione e paradigma del divide et impera 40
2.5.1 Equazioni di ricorrenza e teorema fondamentale 42
2.5.2 Moltiplicazione veloce di due numeri interi 43
2.5.3 Ordinamento per fusione 46
2.5.4 Ordinamento e selezione per distribuzione 49
2.5.5 Alternativa al teorema fondamentale delle ricorrenze 54
2.6 Opus libri: grafica e moltiplicazione di matrici 55
2.6.1 Moltiplicazione veloce di due matrici 60
2.6.2 Sequenza ottima di moltiplicazioni e paradigma della program-
mazione dinamica 62
2.7 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

3 Sequenze: liste 83
3.1 Liste 83
3.1.1 Ricerca, inserimento e cancellazione 84
3.1.2 Liste doppie e liste circolari 86
3.2 Opus libri: problema dei matrimoni stabili 89
3.2.1 Strutture di dati utilizzate 91
3.2.2 Implementazione dell'algoritmo 92
3.3 Liste randomizzate 94
3.4 Opus libri: gestione di liste ammortizzate e ad auto-organizzazione . . . 99
3.4.1 Unione e appartenenza a liste disgiunte 99
3.4.2 Liste ad auto-organizzazione 102
3.4.3 Tecniche di analisi ammortizzata 108

4 Alberi 113
4.1 Alberi binari 113
4.1.1 Algoritmi ricorsivi su alberi binari 116
4.1.2 Inserimento e cancellazione 123
4.2 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 129
4.3 Visita per ampiezza e rappresentazione di alberi 131
4.3.1 Rappresentazione implicita di alberi binari 133
4.3.2 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.4 Alberi cardinali e ordinali, e parentesi bilanciate 143
4.4.1 Rappresentazione succinta mediante parentesi bilanciate . . . . 1 4 6
5 Dizionari 151
5.1 Dizionari 151
5.2 Liste e dizionari 152
5.3 Opus libri: funzioni hash e peer-to-peer 154
5.3.1 Tabelle hash: liste di trabocco 157
5.3.2 Tabelle hash: indirizzamento aperto 158
5.4 Opus libri: kernel Linux e alberi binari di ricerca 161
5.4.1 Alberi binari di ricerca 162
5.4.2 AVL: alberi binari di ricerca bilanciati 165
5.5 Opus libri: basi dati e B-alberi 170
5.6 Opus libri: liste invertite e trie 177
5.6.1 Trie o alberi digitali di ricerca 183
5.6.2 Trie compatti e alberi dei suffissi 190

6 Grafi 199
6.1 Grafi 199
6.1.1 Alcuni problemi su grafi 205
6.1.2 Rappresentazione di grafi 208
6.1.3 Cammini minimi, chiusura transitiva e prodotto di matrici . .213
6.2 Opus libri: colorazione di grafi e algoritmi golosi 215
6.2.1 II problema dell'assegnazione delle lunghezze d'onda 216
6.2.2 Grafi a intervalli 217
6.2.3 Colorazione di grafi a intervalli 219
6.2.4 Massimo insieme indipendente in un grafo a intervalli 221
6.2.5 Paradigma dell'algoritmo goloso 224
6.3 Grafi casuali e modelli di reti complesse 225
6.3.1 Grafi casuali alla Erds-Rnyi 228
6.3.2 Grafi casuali con effetto di piccolo mondo 230
6.3.3 Grafi casuali invarianti di scala 235
6.4 Opus libri: motori di ricerca e classificazione 239
6.4.1 Significativit delle pagine con PageRank 241
6.4.2 Significativit delle pagine con HITS 246
6.4.3 Convergenza del calcolo iterativo di PageRank e HITS 249

7 Pile e code 253


7.1 Pile 253
7.1.1 Implementazione di una pila mediante un array 254
7.1.2 Implementazione di una pila mediante una lista 255
7.2 Opus libri: Postscript e notazione postfissa 256
7.3 Code 259
7.3.1 Implementazione di una coda mediante un array 260
7.3.2 Implementazione di una coda mediante una lista 260
7.4 Opus libri: Web crawler e visite di grafi 261
7.4.1 Visita in ampiezza di un grafo 262
7.4.2 Visita in profondit di un grafo 267
7.5 Applicazioni delle visite di grafi 270
7.5.1 Grafi diretti aciclici e ordinamento topologico 270
7.5.2 Componenti (fortemente) connesse 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 317
9.1 Problemi NP-completi 317
9.1.1 Classi P e NP 318
9.1.2 Riducibilit polinomiale 322
9.1.3 Problemi NP-completi 324
9.1.4 Teorema di Cook-Levin 326
9.1.5 Problemi di ottimizzazione 328
9.2 Esempi e tecniche di NP-completezza 329
9.2.1 Tecnica di sostituzione locale 329
9.2.2 Tecnica di progettazione di componenti 331
9.2.3 Tecnica di similitudine 334
9.2.4 Tecnica di restrizione 334
9.2.5 Come dimostrare risultati di NP-completezza 335
9.3 Algoritmi di approssimazione 337
9.4 Opus libri: il problema del commesso viaggiatore 338
9.4.1 Problema del commesso viaggiatore su istanze metriche . . . .341

9.4.2 Paradigma della ricerca locale 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 ma-
tematico. 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 astrat-


to 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 al-
goritmi 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 proble-
mi 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 mo-
menti di vera e propria opera di progettazione di strutture di dati e di algoritmi, deno-
minata opus libri, in particolare, il libro esplora i seguenti domini applicativi, in ordine
di trattazione:

complessit dei giochi scheduling della CPU grafica computerizzata si-


curezza dei sistemi matrimoni stabili e abbinamento di risorse politica
LRU e ad auto-organizzazione minimo antenato comune e flussi informa-
tivi 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 assegna-
zione 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 esplo-
razione 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 mo-
strandone 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'ap-
proccio 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 soddi-
sfare 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 con-
tinua 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 orga-
nizzare 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 para-
grafi del libro raggruppati per capitoli (ricordiamo di affiancare tali percorsi con l'uso del
software ALVIE e la consultazione del sito Web).

Cap.l Cap. 2 Cap. 3 Cap. 4 Cap. 5 Cap. 6 Cap. 7 Cap. 8 Cap. 9


CFU
1.1-1.4'2.1^.5i2.6 2.7 3.1:3.2 3.313.4 4.1 4.24.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 com-
plessit, 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 affa-
scinanti 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'argo-
mento 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 entusia-
smo 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, An-
drea 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 algo-
ritmi 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 princpi 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 inizializza-
re) 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 com-
putazionali, ovvero problemi risolvibili al calcolatore mediante algoritmi:1 l'algoritmo
l'essenza computazionale di un programma ma non deve essere identificato con que-
st'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 realizza-
re lo stesso algoritmo. Avendo cura di distillare gli aspetti rilevanti ai fini computazionali
(trascendendo quindi dal particolare linguaggio, ambiente di sviluppo o sistema operati-
vo 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 saran-
no 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 struttura-
re 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, pos-
sano 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'informazio-
ne 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 informa-
zione 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 que-
st'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 ai 12 in-l
3 8 1 13 ZM

Figura 1.1 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 rappre-
sentato 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 ricondot-
to 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 nel-
lo 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 com-
prensione del resto del libro, invitando il lettore a visitare il sito web per eventuali appro-
fondimenti 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 estrat-
to 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 gene-
rico 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 alcu-
ni 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 ): {pre: numero > 1)


fattore = 2;
WHILE (numero /0 fattore != 0 )
fattore = fattore + 1;
RETURN (fattore == numero);

Tale codice non particolarmente efficiente: per esempio, potremmo evitare di ve-
rificare 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 im-
mediato. 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;
FOR (p = 2; p <= n-2; p = p + 1) {
q = n - p;
IF (Primo(p) &4t Primo(q)) controesempio = FALSE
>
> 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 compila-
tore): 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'in-
gresso 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 so-
lo 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, ovve-
ro se e solo se il programma P a r a d o s s o non termina quando viene eseguito sui da-
ti 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 program-
ma 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 pro-
grammi 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 ap-
poggio, 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. Suppo-
niamo 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 iterzo;
> ELSE {
TorriHanoi( n - 1, primo, terzo, secondo );
PRINT primo iterzo;
TorriHanoi( n - 1, secondo, primo, terzo );
>
Dimostriamo, per induzione sul numero n di dischi, che il numero di mosse effet-
tuate eseguendo tale programma e stampate come "origine < destinazione", pari a
2 n 1: il caso base n = 1 immediato; nel caso n > 1 occorrono 2 n ~ ' 1 mosse per
ciascuna delle due chiamate ricorsive per ipotesi induttiva, a cui aggiungiamo la mossa
nella riga 6, per un totale di 2 x (2 n ~* 1 ) + 1 = 2 n 1 mosse. Purtroppo, non c'
speranza di trovare un programma che effettui un numero di mosse inferiore a tale quan-
tit, in quanto stato dimostrato che le 2 " 1 mosse sono necessarie e non possibile
impiegarne di meno.
Nel problema originale con n = 64 dischi, supponendo che ogni mossa richieda un
secondo, occorrono 2 6 4 1 = 18 446 744 073 709 551 615 secondi, che equivalgono
a circa 584 942 417 355 anni, ovvero quasi 585 miliardi di anni: per confronto, la
teoria del big bang asserisce che l'Universo stato creato da un'esplosione cosmica in un
periodo che risale a circa 1020 miliardi di anni fa.

ALVIE: p r o b l e m a delle Torri di Hanoi

Osserva, s p e r i m e n t a e verifica
. _ D*oo6tteco5Osco4
HanoiTower

Le Torri di Hanoi mostrano dunque che, anche se un problema decidibile ovvero


risolubile mediante un algoritmo, non detto che l'algoritmo stesso possa sempre ri-
solverlo in tempi ragionevoli: ci dovuto al fatto che il numero di mosse e quindi il
tempo di esecuzione del programma, esponenziale nel numero n di dischi (n appare
all'esponente di 2 n 1). Il tempo necessario per spostare i dischi diventa dunque ra-
pidamente insostenibile, anche per un numero limitato di dischi, come illustrato nella
seguente tabella, in cui il tempo di esecuzione espresso in secondi (s), minuti (m), ore
(h), giorni (g) e anni (a).

n 5 10 15 20 25 30 35 40 45
tempo 32 s 17 m 9 h 12g la 34 a 1089 a 34865 a 1115689 a
L'esponenzialit del tempo di esecuzione rende anche limitato l'effetto di eventuali
miglioramenti nella velocit di esecuzione dei singoli passi, perch, in tal caso, basta au-
mentare di poco il numero n di dischi per vanificare ogni miglioramento. Supponiamo
infatti di poter eseguire m = 2 S operazioni in un secondo, invece di una singola ope-
razione al secondo: in tal caso, anzich circa 2 n secondi, ne occorrono 2 n / m = 2 n ~ s
per spostare gli n dischi. L'effetto di tale miglioramento viene per neutralizzato molto
rapidamente al crescere del numero di dischi in quanto sufficiente portare tale numero
a n + s (dove s = log m) per ottenere lo stesso tempo complessivo di esecuzione. In altre
parole, un miglioramento delle prestazioni per un fattore moltiplicativo si traduce in un
aumento solo additivo del numero di dischi trattabili. La tabella seguente esemplifica
questo comportamento nel caso n = 64, mostrando il numero di dischi gestibili in un
tempo pari a 18 446 744 073 709 551 615 secondi, al variare della velocit di esecu-
zione: come possiamo vedere, miglioramenti anche molto importanti di quest'ultima si
traducono in piccoli incrementi del numero di dischi che il programma in grado di
gestire.

operazioni/sec 1 10 100 IO 3 IO 4 IO 5 IO6 IO9


numero dischi 64 67 70 73 77 80 83 93

Di diversa natura invece l'andamento polinomiale, come possiamo mostrare se


consideriamo la generalizzazione del problema delle Torri di Hanoi al caso in cui siano
disponibili k > 3 pioli. A tale scopo, supponiamo che i pioli siano numerati da 0 a k 1
e che il problema consista nello spostare i dischi dal piolo 0 al piolo k 1 (rispettando
le regole sopra descritte). In tal caso, possiamo usare il codice T o r r i H a n o i come sotto-
programma all'interno della seguente soluzione al problema generalizzato (per semplicit
di esposizione, supponiamo che n > 0 sia un multiplo di k 2).

TorriHanoiGen( n, k ) : (pre: n > 0 multiplo di k - 2)


FOR (i = 1; i <= k-2; i = i+1)
TorriHanoi(n/(k-2), 0, k-1, i);
FQR (i = k-2; i >= 1; i = i-1)
TorriHanoiCn/(k-2), 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 5 10 15 20 25 30 35 40 45
tempo 25 s 100 s 225 s 7 m 11 m 15 m 21 m 27 m 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, poten-
do 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 miglio-
ramento 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 migliora-
mento 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, mo-
strando 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'ulti-
ma si traducono in incrementi significativi del numero di dischi che il programma in
grado di gestire.

operazioni/sec 1 10 100 IO3 IO4 IO5 IO6 IO9


numero dischi 64 202 640 2023 6400 20238 64000 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 rappresenta-
zione 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) se-
gnala 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 signi-
ficato 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 signi-
ficativo bo, ottenendo bi x 2V. Per esempio, la sequenza 0101 codifica il numero
intero 5 = 0 x 2 + 1 x 2 + 0 x 2 1 + 1 x 2. La regola inversa pu essere data in vari modi,
3 2

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
{UnicodeUTF8). La codifica riflette l'ordine alfabetico, per cui la lettera 'A' viene codi-
ficata 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 ne-
cessario, 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.
4
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.
Figura 1.2 Una prima classificazione dei problemi computazionali decidibili.

1.2.2 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'ulte-
riore 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 algorit-
mo 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 corri-
spondente 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 ): {pre: i primi b bit in A sono da generare)


IF (b == 0) {
Elaborai A );
> 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 con-


tenuti 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, pos-
siamo 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 c d a b d c a d c b d b c a
b a c d b a d e d a c b b d c a
a c b d a d b c a c d b d c b a
c a b d d a b c c a d b c d b. a
c b a d d b a c c d a b c b d a
b c a d b d a c d c a b b c d a

Restringendoci alle permutazioni aventi d in ultima posizione (prima colonna), pos-


siamo 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 ): {pre: iprimi p elementi di A sono dapermutare)
IF (p == 0) {
Elaborai A );
> 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 fa-
cilmente 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 inequivoca-
bilmente 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 sem-
pre di tale livello di difficolt: le configurazioni pi difficili raramente consentono di

3 9 8 3 9 6 5 1 2 4 7 8
7 1 3 4 7 1 6 8 3 5 9 2
8 4 9 6 2 5 8 7 4 9 3 6 1
1 2 7 9 1 3 4 2 7 5 6 8 9
6 3 6 8 7 4 9 1 2 5 3
5 3 6 4 5 2 9 8 3 6 7 1 4
4' 1 5 9 8 4 2 1 5 7 9 3 6
9 8 2 7 1 3 9 6 4 8 2 5
9 4 7 9 6 5 3 2 8 1 4 7

Figura 1.3 Un esempio di istanza del gioco del Sudoku e la corrispondente soluzione.
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 confi-
gurazione 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 uni-
voco. 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 implicitamen-
te 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 tro-
vata. 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 procedi-

6
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.
6 2 9 6 2 9
6 8 6
7 3 1 5 8 7 3 1 5 8
4 9 3 6 5 4 2 9 3 1 8 6 7 5
3 1 6 7 3 2 1 8 4
5 8 7 9 2 5 1 8 4 6 7 9 3 2
1 5 2 3 1 5 2 3
7 7 1 8 6
6 2 9 4 6 2 9 7 4

Figura 1.4 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 inizial-
mente vuote: infatti, per ogni casella vuota della tabella vi sono al pi 9 possibili ci-
fre con cui tentare di riempire tale casella. In generale, usando n cifre (con n nume-
ro 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 pos-
sa 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 poli-
nomiale (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. Eb-
bene, 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 proble-


mi 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 poli-
nomiali: 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 polinomia-
le, 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
(NPC), che sono stati introdotti indipendentemente all'inizio degli anni '70 da due in-
formatici, 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 intrattabi-
le (e quindi che la classe NP diversa dalla classe P), allora risultano intrattabili tutti i
problemi in 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 richiedo-
no 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 pre-
mio 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 apparen-
te 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 Figu-
ra 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 memo-
ria 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 di-
retto) 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 pre-
sentati nel libro, seguiamo la convenzione comunemente adottata di assegnare un costo
uniforme alle suddette operazioni. In particolare, supponiamo che ciascuna di esse ri-
chieda 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 com-
plessit 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.
8
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.
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 co-
stante.
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'invocazio-
ne (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 adotta-
to, 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'applicazio-
ne 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 comples-
sit dei relativi algoritmi di risoluzione. In particolare, abbiamo visto come la trattabilit
venga fatta coincidere con l'esistenza di algoritmi risolutivi con complessit polinomiale, for-
nendo 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 pro-


gramma 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:

i l se i = 1,2
llJ
~ \ 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 cal-
coli 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 rap-
porto 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 al-
l'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 me-
moria 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 del-
la 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 or-
dine relativo: quindi, la sequenza ottenuta invertendo l'ordine di due qualunque ele-
menti 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 saldo transazione saldo transazione
0 0
deposita 1000 1000 deposita 1000 1000 deposita 1000
deposita 1000 2000 deposita 1000 2000 preleva 1500
preleva 1500 500 preleva 500 1500 deposita 1000
preleva 500 0 preleva 1500 0 preleva 500

Figura 2.1 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. Supponia-
mo 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 in-
vertiamo 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 diret-
to 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 se-
quenza 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 con-
tiene 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 lo-
cazione con indirizzo x + 8, e cos via. In altre parole, conoscendo il valore x, l'indirizzo
a
0
n

a3 4

di

* 12

Figura 2.3 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 me-
moria 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 com-
putazione 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 in-
dirizzo 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 succes-
sivo. Per accedere al terzo elemento, ovvero ad Q2, necessario partire dall'inizio della
lista, ovvero da a, per accedere ad ci, Pi 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: di-
versi 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 ridimen-
sionamento. Allocare un nuovo array (pi grande o pi piccolo) per copiarvi gli elemen-
ti di a a ogni variazione della sua dimensione pu richiedere O(n) tempo per ciascu-
na operazione, risultando particolarmente oneroso in termini computazionali, sebbene
sia ottimale in termini di memoria allocata. Con qualche piccolo accorgimento, tutta-
via, 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, dimezzia-
mo 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 ridimensio-
namento di a (raddoppio o dimezzamento) al costo di O(n) tempo per ciascuna opera-
zione. 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 dimezzamen-
to, 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 ridi-
mensionamento pu essere virtualmente distribuito tra le O(n) operazioni che lo hanno
causato (a partire dal precedente ridimensionamento). Tale costo pu essere concettual-
mente 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 P2 P 3
0 21 2425 27

P. P3 Po P2
0 3 5 2627

p 2 P3 Pi Po
0 1 3 6 27

Figura 2.4 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, appli-
cando 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 par-
te 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 quat-
tro 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 ope-
razioni pi frequenti in diverse applicazioni informatiche, e nel seguito ne discuteremo
ulteriormente. In questo paragrafo, mostriamo come ottenere un ordinamento mediante
SelectionSort ( a ) : (pre: la lunghezza di a n)
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;
}
>
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 elemen-


ti 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
TI 1 . -a \
.\ V-. nn+1 2
1 =
2 _ (n. - 1 ) = 2 _ = 2
i=0 i=l

In altre parole, il selection sort un algoritmo di ordinamento con complessit quadra-


tica 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 para-
grafo 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 nel-
l'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'e-
lemento 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 po-
sizione 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.
InsertionSort ( a ): (pre: la lunghezza di a n)
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 Osserva, s p e r i m e n t a e verifica
^LBp^ InsertionSort

2.3 Complessit di problemi computazionali


Per illustrare i principi di base che guidano la metodologia di progettazione degli algo-
ritmi e la loro analisi di complessit in termini di tempo e spazio, consideriamo un pro-
blema "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 <= j; k = k+1)
somma = somma + a[k];
IF (somma > 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 mas-
sima 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 soltan-
to 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 ave-
re 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 mag-


giore 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 seg-
mento 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 adotta-
to 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 gene-
rica 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 ) ) . No-
tiamo 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 so-
luzione 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 ricor-
dare 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<n) && (trovato); i = i+1) {
IF (a[i] == k) {
trovato = TRUE;
indice = i;
>
>
RETURN indice;

Codice 2.7 Ricerca sequenziale di una chiave k in un array a.

2.4 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 con-
tenuti 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'al-
goritmo 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 ) : {pre: la lunghezza di a n)
sinistra = 0;
destra = n-1;
trovato = FALSE;
indice = -1;
WHILE ((sinistra <= destra) && (trovato)) {
centro = (sinistra+destra)/2;
IF (a[centro] > k) {
destra = centro-1;
> ELSE IF (a[centro] < k) {
sinistra = centro+1;
> ELSE {
indice = centro;
trovato = TRUE;
}
>
RETURN indice;

Codice 2.8 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. Con-
frontiamo 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 da-
gli 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 posizio-
ne 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 va-
le 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] oppu-
re 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'e-
lemento 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 immedia-
tamente 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 memoriz-
zato 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 specifi-
cata 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 proble-
mi 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 ami-
co. 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 inferio-
re 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 se-
quenza 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'informazio-
ne. Sia A un qualunque algoritmo di ricerca che usa confronti tra coppie di elemen-
ti. 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 di-
scernere 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 vie-
ne 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 proble-
ma della ricerca per confronti. Poich limite superiore e inferiore coincidono asintotica-
mente, 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 <= a[centro]) {
RETURN RicercaBinariaRicorsiva( a, k, sinistra, centro );
} ELSE {
RETURN RicercaBinariaRicorsiva( a, k, centro+1, destra );
>
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 sotto-
problemi 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, cia-
scuno 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'appli-
cazione 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 chiara-
mente 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 elemen-
tare, 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 com-
binazione 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 ope-
razioni 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 nume-
ro totale T(n) di passi eseguiti su un array di n elementi verifica la seguente equazione di
ricorrenza:

T(n) = (2-1)
{ T(n/2) + c' altrimenti
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 ) se n i : n 0 2)
1 V
' \ a T ( n / P ) + f(n) altrimenti ' '

(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 (cia-
scuna 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 sotto-
problema 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 rappre-
sentato 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 arbitraria-
mente 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

f c se n = 1
T(n) = (2 3)
{ 4T(n/2)+c'n altrimenti '

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 affer-
mare 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. Osser-
viamo 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<i + x d y s = x s y s + x d y d - (x s - x d ) x (y s - y d )

Quest'osservazione permette di formulare un nuovo algoritmo di moltiplicazione, de-


scritto nel Codice 2.10, che richiede soltanto tre moltiplicazioni (righe 11, 14 e 16) e
un numero costante di somme: a tal fine, utilizziamo la funzione Somma per calcolare
MoltiplicazioneVeloceC x, y, n ): (pre: x e y interi di n cifre)
IF (n == 1) {
prodottoti] = (x[l] x y[1]) / 10;
prodotto [2] = (x[l] x y[l]) '/. 10;
> ELSE {
xs[0] = xd[0] = ys [0] = yd[0] = 1;
FOR (i = 1; i <= n/2; i = i + 1) {
xs[i] = x[i] ; ys[i] = y[i] ;
xd[i] = x[i + n/2]; yd[i] = y[i + n/2];
}
pi = MoltiplicazioneVeloceC xs, ys, n/2 );
FOR (i = 0; i <= n; i = i+1)
{ prodottoti] = piti]; prodottoti+n] = 0; }
p2 = MoltiplicazioneVeloceC xd, yd, n/2 );
xdtO] = yd[0] = -1;
p3 = MoltiplicazioneVeloceC Somma(xs.xd), SommaCys,yd), n/2);
P 3t0] = -p310];
add = Sommai pi, p2, p3 );
parziale t0] = add[0];
FOR (i = 1; i <= 3 x n/2; i = i+1)
{ parziale ti] = addti + n/2]; parziale ti + n/2] = 0; >
prodotto = Somma( prodotto, parziale, p2 );
>
prodotto[0] = xtO] x y[0];
RETURN prodotto ; {post: prodotto intero di 2n cifre)

Codice 2.10 Moltiplicazione mediante la tecnica del divide et impera, dove Somma calcola
l'addizione di al pi tre numeri interi rappresentati come array, in tempo lineare.

l'addizione di al pi tre numeri interi in tempo lineare nel loro numero totale di cifre
decimali e, attraverso Somma, possiamo ottenere la sottrazione complementando il segno
dell'operando sottratto poich x y = x + (y ). Seppur concettualmente semplice, l'al-
goritmo richiede un'implementazione attenta delle varie operazioni, come mostrato nel
Codice 2.10, il quale prende in ingresso due numeri con n cifre decimali e ne restituisce
il prodotto su 2 n cifre.
Nel caso base (n = 1) effettuiamo il prodotto diretto delle singole cifre, riportandone
il risultato su due cifre (righe 34) e il relativo segno (riga 24). Nel passo induttivo,
calcoliamo x s , Xd,y s e y^ prendendo l'opportuna met delle cifre dal valore assoluto di x
e y (righe 6-10). Calcoliamo quindi p i = x s y s ricorsivamente su n / 2 cifre (laddove p i
ne ha n), e poniamo in p r o d o t t o (che ha 2 n cifre) il valore di 10 n x x s y s (righe 1113).
Procediamo con il calcolo ricorsivo di p2 = Xjyd (riga 14) e p 3 = (x s Xd) x (y s y d )
(righe 15-16), entrambi di 2 n cifre (notiamo che sia x s Xd che y s yd richiedono
n / 2 cifre). Poniamo il risultato di p 1 + p2 p3 = x s y d +XdPs in a d d (righe 17-18), la
cui moltiplicazione per 10 n ^ 2 viene memorizzata in p a r z i a l e (righe 19-21). A questo
punto, per ottenere il prodotto tra il valore assoluto di x e quello di y, sufficiente
calcolare la somma tra p r o d o t t o (che contiene 10 n x x s y s ) , p a r z i a l e (che contiene
1 0 n / 2 x (xsyd + xy s )) e p2 = Xdyd (riga 22). Il segno del prodotto viene infine
calcolato nella riga 24. Il numero totale di passi eseguiti quindi pari a

{ 3T(n/2) + c ' n altrimenti

dove c e c' sono due costanti positive. Applicando il teorema fondamentale delle ri-
correnze alla (2.4), otteniamo a = 3, (3 = 2 e f(n) = c ' n nell'equazione (2.2): an-
che questa volta rientriamo nel terzo caso del teorema con y' = che consente di
affermare che il numero di passi richiesti 0(n'8 3 ) = 0 ( n 1 , 5 8 5 ) , ottenendo un signi-
ficativo miglioramento rispetto alla complessit quadratica dei precedenti algoritmi di
moltiplicazione.
L'idea alla base del Codice 2.10 pu essere ulteriormente sviluppata spezzando i nu-
meri in parti pi piccole, per ottenere la moltiplicazione in O (n log n log log n) passi,
analogamente a quanto accade nella trasformata veloce di Fourier, che di grande im-
portanza per una grande variet di applicazioni, che vanno dall'elaborazione di segnali
digitali alla soluzione numerica di equazioni differenziali.

ALVIE: moltiplicazione veloce di numeri interi

Osserva, sperimenta e verifica


FastlntegerProduct

2.5.3 Ordinamento per fusione


Gli algoritmi di ordinamento che abbiamo descritto nel Paragrafo 2.2 hanno entrambi
una complessit quadratica nel numero di elementi dl ordinare. Ragionando in modo
simile a quanto fatto per determinare un limite inferiore alla complessit della ricerca per
confronti, un qualunque algoritmo di ordinamento, dopo avere effettuato t confronti,
pu discernere al pi 3 1 situazioni distinte. Poich il numero di possibili ordinamenti
di n elementi pari a n!, ovvero al numero di loro permutazioni, viene richiesto all'al-
goritmo di discernere tra n! possibili situazioni: pertanto, deve valere 3 l ^ n! perch
MrgeSort( a, sinistra, destra ):
{pre: 0 ^ sinistra, destra ^ n 1)
IF (sinistra < destra) {
centro = (sinistra+destra)/2;
MergeSort( a, sinistra, centro );
MergeSort( a, centro+1, destra );
FusioneC a, sinistra, centro, destra );

Codice 2.11 Ordinamento per fusione di un array a.

altrimenti l'algoritmo certamente non sarebbe corretto. Dalla disuguaglianza

n!=n(n-l)---l>n(n-l)---Q + l)> = (n/2)"/2

n/2 volte

deriva che 3* ^ ( n / 2 ) n / 2 e che quindi occorrono t ^ ( n / 2 ) log 3 (n/2) = O ( n l o g n )


confronti: ci rappresenta un limite inferiore per il problema dell'ordinamento per con-
fronti. Tale limite inferiore lascia quindi un margine per un potenziale miglioramen-
to delle prestazioni rispetto alla complessit 0 ( n 2 ) degli algoritmi di ordinamento per
inserimento e per selezione.
Facendo uso del paradigma del divide et impera, siamo ora in grado di formulare un
algoritmo ottimo, detto algoritmo di ordinamento per fusione {mergesort), che opera in
tempo O ( n l o g n ) nel modo seguente.
Decomposizione: se la sequenza ha almeno due elementi, dividila in due sotto-sequenze
uguali (o quasi) in lunghezza (nel caso in cui abbia meno di due elementi non vi
nulla da fare).
Ricorsione: ordina ricorsivamente le due sotto-sequenze.
Ricombinazione: fondi le due sotto-sequenze ordinate in un'unica sequenza ordinata.
L'algoritmo di ordinamento per fusione descritto nel Codice 2.11. Per implemen-
tare l'algoritmo per necessario specificare come le due sotto-sequenze ordinate possano
essere fuse. Esistono diversi modi per far ci, sia mediante l'uso di memoria addizionale
che operando in loco: poich in quest'ultimo caso l'algoritmo di fusione risulta piutto-
sto complicato, preferiamo fornire la soluzione che fa uso di un array aggiuntivo. Tale
soluzione si ispira al metodo utilizzato per fondere due mazzi di carte ordinati in modo
crescente. In tal caso, a ogni passo, per determinare la carta di valore minimo nei due
mazzi sufficiente confrontare le due carte in cima ai mazzi stessi: tale carta pu essere
quindi inserita in fondo al nuovo mazzo.
Sia a un array e siano a[sx, ex] e a[cx + 1, dx] due segmenti adiacenti di a, ciascuno
ordinato in modo non decrescente. Per ottenere che l'intero segmento a[sx, dx] sia ordi-
nato, possiamo utilizzare un array b d'appoggio che viene riempito nel modo seguente.
Partendo da i = s x e j = ex + 1, memorizziamo il minimo tra a[i] e a[j] nella prima
posizione libera di b: se tale minimo a[i], allora incrementiamo di 1 il valore di i, altri-
menti incrementiamo di 1 il valore di ). Ripetiamo questo procedimento fino quando i
diviene maggiore di ex oppure j diviene maggiore di dx: nel primo caso, memorizziamo
i rimanenti elementi del segmento a [ c x + 1, dx] (se ve ne sono) nelle successive posizioni
libere di b, mentre, nel secondo caso, memorizziamo i rimanenti elementi del segmento
a[sx, ex] (se ve ne sono) nelle successive posizioni libere di b. Al termine di questo ciclo,
b conterr gli elementi del segmento a[sx, dx] ordinati in modo non decrescente, per
cui sar sufficiente ricopiare b all'interno di tale segmento.
La fusione di due sequenze ordinate appena descritta realizzata nel Codice 2.12.
Poich a ogni iterazione del ciclo w h i l e , l'indice t (che parte da s x e arriva al massimo
a ex) oppure l'indice ) (che parte da cx+1 e arriva al massimo a dx) aumenta di 1, tale
ciclo pu essere eseguito al pi (ex s x + 1) + (dx ex) = dx s x + 1 volte. Uno
solo dei due cicli f o r successivi (righe 1618) viene eseguito, per al pi ex s x + 1 e
dx ex iterazioni, rispettivamente. Infine, l'ultimo ciclo f o r verr eseguito dx s x + 1
volte: pertanto, la fusione dei due segmenti richiede un numero di passi 0 ( d x sx),
ovvero linearmente proporzionale alle lunghezze dei due segmenti da fondere.
Siamo adesso in grado di completare la descrizione dell'algoritmo di ordinamento
per fusione, che mostrato nel Codice 2.11. Le prime tre istruzioni della struttura i f
(che vengono eseguite solo se vi sono almeno due elementi da ordinare) corrispondono
alla fase di decomposizione del problema (riga 4) e a quella di soluzione ricorsiva dei
due sotto-problemi (righe 5 e 6). L'invocazione della funzione F u s i o n e realizza la fase
di ricombinazione (riga 7), fondendo in un unico segmento ordinato i due segmenti
ordinati prodotti dalla ricorsione.
Poich ci sono due chiamate ricorsive sulla met degli elementi e il calcolo della
funzione F u s i o n e richiede tempo lineare, possiamo affermare che il numero totale di
passi eseguiti dal Codice 2.11 su un array di n elementi pari a
se n = 1
(2.5)
altrimenti
dove c e c' sono due valori costanti. Applicando il teorema fondamentale delle ricorrenze
all'equazione (2.5), otteniamo a = 2, (3 = 2 e f(n) = c'n: anche questa volta rientriamo
nel secondo caso del teorema, in quanto af(n/|3) = 2 c ' n / 2 = c ' n = f(n). Quindi,
il numero di passi richiesti dall'ordinamento per fusione su un array di n elementi
O ( n l o g n ) , e abbiamo pertanto dimostrato che tale algoritmo ottimo.
Osservando che uno stesso vettore b di n elementi pu essere riutilizzato per tut-
te le operazioni di fusione, possiamo concludere che l'algoritmo richiede O(n) spazio
Fusione( a, sx, ex, dx ): (pre: 0 ^ sx ^ ex ^ dx ^ n 1)
i = sx;
j = cx+1;
k = 0;
WHILE ((i <= ex) && (j <= dx)) {
IF (a[i] <= a[j] ) {
b[k] = a[i] ;
i = i+1;
> ELSE {
b[k] = a[j] ;
j = j+i;
>
k = k+1;
>
FOR ( ; i <= ex; i = i+1, k = k+1)
b [k] = a[i] ;
FOR ( ; j <= dx; j = j+1, k = k+1)
b[k] = a[j] ;
FOR (i = sx; i <= dx; i = i+1)
a[i] = b[i-sx] ;

Codice 2.12 Fusione di due segmenti adiacenti ordinati.

aggiuntivo. Notiamo, infine, che la scansione sequenziale dei dati da fondere rende l'or-
dinamento per fusione uno dei principali metodi di ordinamento per grandi quantit
dati che risiedono nella memoria secondaria del calcolatore (ad esempio, un disco rigi-
do), notoriamente con accesso pi lento della memoria principale (che non pu ospitare
tutti i dati in tale scenario).

ALVIE: ordinamento perfusione

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

2.5.4 Ordinamento e selezione per distribuzione


L'algoritmo di ordinamento per distribuzione (quicksort) segue il paradigma del divide
et impera come quello per fusione ma, al contrario di quest'ultimo, la fase di decompo-
QuickSort( a, sinistra, destra ):
{pre: 0 < sinistra, destra ^ n 1)
IF (sinistra < destra) {
scegli pivot nell'intervallo [sinistra...destra];
perno = Distribuzione( a, sinistra, pivot, destra );
QuickSort( a, sinistra, perno-1 );
QuickSort( a, perno+1, destra );

Codice 2.13 Ordinamento per distribuzione di un array a.

sizione pi evoluta mentre quella di ricombinazione immediata. In particolare, tale


algoritmo opera nel modo seguente.
Decomposizione: se la sequenza ha almeno due elementi, scegli un elemento pivot e
dividi la sequenza in due sotto-sequenze, dove la prima contiene elementi minori
o uguali al pivot e la seconda contiene elementi maggiori o uguali.
Ricorsione: ordina ricorsivamente le due sotto-sequenze.
Ricombinazione: concatena (implicitamente) le due sotto-sequenze ordinate in un'uni-
ca sequenza ordinata.
Il Codice 2.13 implementa l'algoritmo di ordinamento per distribuzione secondo lo
schema descritto sopra. Nella riga 4, viene scelta una posizione p i v o t : come vedre-
mo, la scelta della posizione rilevante ma, per adesso, supponiamo di scegliere sempre
l'ultima posizione nel segmento a [ s i n i s t r a , d e s t r a ] . Nella riga 5, gli elementi sono
distribuiti all'interno del segmento in modo che l'elemento pivot vada in a[perno]: ne
risulta che gli elementi del segmento a [ s i n i s t r a , p e r n o 1] sono minori o uguali di
a [ p e r n o ] mentre quelli del segmento a [ p e r n o + 1, d e s t r a ] sono maggiori o uguali di
a[perno]. Come possiamo osservare, dopo la ricorsione (righe 6 - 7 ) la ricombinazione
praticamente nulla.
Il passo fondamentale di distribuzione degli elementi in base al pivot rappresentato
da D i s t r i b u z i o n e , illustrato nel Codice 2.14. Utilizzando una primitiva S c a m b i a per
scambiare il contenuto di due posizioni nel segmento, l'elemento pivot viene spostato
nell'ultima posizione del segmento (riga 2). Le rimanenti posizioni sono scandite con
due indici (righe 3 e 4): una scansione procede in avanti finch non trova un elemento
maggiore del pivot (righe 6 e 7) mentre l'altra procede all'indietro finch non trova un
elemento minore del pivot (righe 8 e 9). Un semplice scambio dei due elementi fuori
posto (riga 10) permette di procedere con le due scansioni, che terminano quando gli
elementi scanditi si incrociano, uscendo di fatto dal ciclo esterno (righe 5-11). Un
ulteriore scambio (riga 12) permette di collocare il pivot nella posizione i che viene
Distribuzione( a, sx, px, dx ): (pre: 0 ^ sx ^ px < dx < n 1)
IF (px != dx) Scambiai px, dx );
i = sx;
j = dx-1;
WHILE (i < j) {
WHILE ( ( i < j ) kk (A[i] <= A [ d x ] ) )
i = i+1;
WHILE ((i < j) k& (A [j] => A [dx]))
j = j-i;
IF (i < j) Scambiai i, j );
>
IF (i != dx) Scambiai i, dx );
RETURN I;

Scambiai i, j ): (pre: sx ^ i, j ^ dx)


temp = a[j] ; a[j] = a[i] ; a[i] = temp;

Codice 2.14 Distribuzione in loco degli elementi di un segmento a[sx, dx] in base alla posizione
px scelta per il pivot.

restituita come p e r n o nell'algoritmo di ordinamento (riga 13). Poich le due scansioni


sono eseguite in un tempo complessivamente lineare, il costo di D i s t r i b u z i o n e O(n)
tempo e 0 ( 1 ) spazio (in quanto usiamo un numero costante di variabili di appoggio).

ALVIF: o r d i n a m e n t o per d i s t r i b u z i o n e

Osserva, sperimenta e verifica


QuickSort

Notiamo che il tempo di esecuzione dell'algoritmo di ordinamento per fusione pu


essere 0 ( n 2 ) se l'array gi ordinato: in tal caso, infatti, a ogni chiamata ricorsiva la di-
stribuzione degli elementi estremamente sbilanciata, risultando sempre n 1 elementi
ancora da ordinare. Non difficile vedere che in questa situazione, l'analisi del costo
simile a quella dell'ordinamento per selezione o per inserimento. Se invece la distribuzio-
ne bilanciata, la ricorsione avviene su ciascuna met e otteniamo un costo la cui analisi
simile a quella dell'ordinamento per fusione, fornendo un costo di O ( n l o g n ) tempo
(che rappresenta il caso migliore che possa capitare). Per il caso medio, possibile di-
mostrare che l'algoritmo richiede O ( n l o g n ) tempo perch si possono alternare, durante
la ricorsione, situazioni che danno luogo a una distribuzione sbilanciata con situazioni
che conducono a distribuzioni bilanciate. Tuttavia, tale costo medio dipende dall'ordine
iniziale con cui sono presentati gli elementi nell'array da ordinare.
Mostriamo ora un'analisi al caso medio pi robusta che risulta essere indipendente
dall'ordine iniziale degli elementi nell'array e si basa sull'uso della casualit per far s che
la distribuzione sbilanciata occorra con una probabilit trascurabile: semplicemente sce-
gliamo p i v o t in modo aleatorio, equiprobabile e uniforme, nella riga 4 del Codice 2.13.
Il risultato di tale scelta casuale che il valore di p e r n o restituito nella riga 5 uniforme-
mente distribuito tra le (equiprobabili) posizioni del segmento a [ s i n i s t r a , d e s t r a ] .
Supponiamo pertanto di dividere tale segmento in quattro parti uguali, chiamate zone.
In base a quale zona contiene la posizione p e r n o restituita nella riga 5, otteniamo i
seguenti due eventi equiprobabili:
la posizione p e r n o ricade nella prima o nell'ultima zona: in tal caso, p e r n o
detto essere esterno-,
la posizione p e r n o ricade nella seconda o nella terza zona: in tal caso, p e r n o
detto essere interno.
Indichiamo con T(n) il costo medio dell'algoritmo Q u i c k S o r t eseguito su n dati
in ingresso. Osserviamo che la media ^y^ di due valori x e y pu essere vista come la
loro somma pesata con la rispettiva probabilit 5, ovvero j x + j y , considerando i due
valori come equiprobabili. Nella nostra analisi, x e y sono sostituiti da opportuni valori
di T(n) corrispondenti ai due eventi equiprobabili sopra introdotti. Pi precisamente,
quando p e r n o esterno (con probabilit 5), la distribuzione pu essere estremamente
sbilanciata nella ricorsione e, come abbiamo visto, quest'ultima pu richiedere fino a
x = T(n 1) + c ' n ^ T(n) + O(n) tempo, dove il termine O(n) si riferisce al costo
della distribuzione effettuata nel Codice 2.14. Quando invece p e r n o interno (con
probabilit 5), la distribuzione pi sbilanciata possibile nella ricorsione avviene se p e r n o
corrisponde al minimo della seconda zona oppure al massimo della terza. Ne deriva una
distribuzione dei dati che porta alla ricorsione su circa j elementi in una chiamata di
Q u i c k S o r t e | n elementi nell'altra (le altre distribuzioni in questo caso non possono
andare peggio perch sono meno sbilanciate). In tal caso, la ricorsione richiede al pi
y = T( j ) + T( + O(n) tempo. Facendo la media pesata di x e y, otteniamo

(2.6)

per un'opportuna costante c' > 0. Moltiplicando entrambi i termini nella (2.6) per 2 e
risolvendo rispetto a T(n), otteniamo

(2.7)
QuickSelect( a, sinistra, r, destra ):
(pre: 0 ^ sinistra ^ r 1 ^ destra ^ n 1)
IF (sinistra == destra) {
RETURN a[sinistra];
> ELSE {
scegli pivot nell'intervallo [sinistra...destra];
perno = Distribuzione( a, sinistra, pivot, destra );
IF (r-1 <= perno) {
QuickSelectC a, sinistra, r, perno );
> ELSE {
QuickSelectC a, perno+1, r, destra );
>
>

Codice 2.15 Selezione dell'elemento di rango r per distribuzione in un array a.

che simile a un'equazione di ricorrenza se non per il fatto che al posto dell'uguaglianza
appare una disuguaglianza. Consideriamo allora la seguente equazione di ricorrenza

se n = 1
(2.8)
altrimenti

la cui soluzione dimostriamo essere T ' ( n ) = O ( n l o g n ) nel Paragrafo 2.5.5. Poich


T(n) ^ T'(n), ne deriva che T(n) = O ( n l o g n ) al caso medio e che questo dipende
dalle scelte casuali di pivot piuttosto che dalla configurazione dei dati in ingresso: un tale
algoritmo si chiama casuale o random perch impiega la casualit per sfuggire a situazioni
sfavorevoli, risultando pi robusto rispetto a tali eventi (per esempio, in presenza di un
array gi in ordine crescente). Inoltre, pu essere implementato usando solo O(logn)
celle di memoria aggiuntive in media.
Come il suo nome suggerisce, l'algoritmo di quicksort molto veloce in pratica e vie-
ne usato diffusamente per ordinare i dati in memoria principale (laddove l'ordinamento
per fusione M e r g e S o r t utile soprattutto in memoria secondaria). La libreria standard
del linguaggio C usa un algoritmo di quicksort in cui il caso base della ricorsione si ferma
quando n ^ no per una certa costante no > 1 : terminata la ricorsione, ogni segmento di
al pi no elementi va ordinato individualmente, ma basta una singola passata dell'ordi-
namento per inserzione per ordinare tutti questi segmenti in 0 ( n x UQ) = O(n) tempo
(risparmiando la maggior parte delle chiamate ricorsive). In altre librerie standard, come
quella del linguaggio C++, quando l'algoritmo di quicksort inizia a distribuire i dati in
maniera sbilanciata, viene sostituito dall'algoritmo di ordinamento per fusione.
Possiamo modificare lo schema ricorsivo del Codice 2.13 per risolvere il problema
della selezione dell'elemento con rango r in un array a di n elementi distinti, senza biso-
gno di ordinarli (ricordiamo che a contiene r elementi minori o uguali di tale elemento):
notiamo che tale problema diventa quello di trovare il minimo in a quando r = 1 e il
massimo quando r = n . Per risolvere il problema per un qualunque valore di r con
1 ^ T ^ n , osserviamo che la funzione D i s t r i b u z i o n e del Codice 2.14 restituisce il
rango del pivot px e posiziona tutti gli elementi di rango inferiore alla sua sinistra e tutti
quelli di rango superiore alla sua destra. In base a tale osservazione, possiamo modificare
il codice di ordinamento per distribuzione considerando che, per risolvere il problema
della selezione, sufficiente proseguire ricorsivamente nel solo segmento dell'array con-
tenente l'elemento da selezionare: otteniamo cos il Codice 2.15, che determina tale
segmento sulla base del confronto tra T 1 e p e r n o (righe 8-12). La ricorsione ha
termine quando il segmento composto da un solo elemento, nel qual caso, il codice re-
stituisce tale elemento (notiamo che alcuni elementi dell'array sono stati spostati durante
l'esecuzione dell'algoritmo).
L'equazione di ricorrenza per il costo al caso medio costruita in modo simile al-
l'equazione (2.6), con la differenza che conteggiamo una sola chiamata ricorsiva (la pi
+
sbilanciata) ottenendo T(n) ^ | n ) + c ' n . Moltiplicando entrambi i termi-
ni per 2 e risolvendo rispetto a T(n), otteniamo T(n) < T ( | n ) + 2c'n, a cui associamo
un'equazione di ricorrenza in cui T'(n) appare al posto di T(n) e la disuguaglianza diven-
ta un'uguaglianza, come nell'equazione (2.8). Possiamo questa volta applicare il teorema
fondamentale delle ricorrenze ponendo a = 1, |3 = | e f ( n ) = 2c ' n nell'equazione (2.2),
per cui rientriamo nel primo caso (y = | ) ottenendo in media una complessit tempora-
le O(n) per l'algoritmo random di selezione per distribuzione (osserviamo che esiste un
algoritmo lineare al caso pessimo, ma d'interesse pi teorico).

2.5.5 Alternativa al teorema fondamentale delle ricorrenze


L'equazione di ricorrenza (2.8) non risolvibile con il teorema fondamentale delle ricor-
renze, in quanto non un'istanza dell'equazione (2.2). In generale, quando un'equazio-
ne di ricorrenza non ricade nei casi del teorema fondamentale delle ricorrenze, occorre
determinare tecniche di risoluzione alternative. Nello specifico dell'equazione (2.8), no-
tiamo che il valore T'(n) (livello 0 della ricorsione) ottenuto sommando a 2 c ' n i valori
restituiti dalle due chiamate ricorsive: quest'ultime, che costituiscono il livello 1 della
ricorsione, sono invocate l'una con input ^ e l'altra con input | n e, in corrispondenza
di tale livello, contribuiscono al valore T'(n) per un totale di 2 c ' ^ + 2 c ' | n = 2c'n.
Passando al livello 2 della ricorsione, ciascuna delle chiamate del livello 1 ne genera
altre due, per un totale di quattro chiamate, rispettivamente con input ^n, ^ n e
che contribuiscono al valore T'(n) per un totale di 2c'^ + 2 c ' ^ n + 2 c ' ^ n +
2C|TT1 = 2 c ' n in corrispondenza del livello 2. Non ci dovrebbe soprendere, a questo
punto, che il contributo del livello 3 della ricorsione sia al pi 2 c ' n (in generale qualche
chiamata ricorsiva pu raggiungere il caso base e terminare).
Per calcolare il valore finale di T'(n) in forma chiusa, occorre sommare i contri-
buti di tutti i livelli. Il livello s pi profondo si presenta quando seguiamo ripetuta-
mente il "ramo ovvero viene soddisfatta la relazione ^ n = 1 da cui deriva che
s = l o g ^ j n = O(logn). Possiamo quindi limitare superiormente T'(n) osservando
che ciascuno degli O(logn) livelli di ricorsione contribuisce al suo valore per al pi
2 c ' n = O(n) e, pertanto, T'(n) = O ( n l o g n ) . Intuitivamente, dividere l'input n in
proporzione a | e invece che (come accade nel caso dell'algoritmo di ordina-
mento per fusione), fornisce comunque una partizione bilanciata perch la dimensione
di ciascuna parte differisce dall'altra soltanto per un fattore costante. La propriet che
T'(n) = O ( n l o g n ) pu essere estesa a una partizione di n in proporzione a j e | e, in
generale, in proporzione a 6 e 1 6 per una qualunque costante 0 < 6 < 1.
Da quanto discusso finora, possiamo dedurre una linea guida per lo sviluppo di una
forma chiusa della soluzione T(n) di un'equazione di ricorrenza del tipo

T(rL = / OH) s e n ^ no (J
1 K
' \ T(6n) + T(pn) + f(n) altrimenti '

per due costanti positive 6 e p tali che 6 -I- p ^ 1. Non potendo applicare il teorema
fondamentale delle ricorrenze, procediamo per passaggi intermedi con le corrispondenti
chamate ricorsive. La chiamata ricorsiva iniziale (livello 0) con input n contribuisce al
valore di T(n) per un totale di f(n) e d luogo a due chiamate ricorsive di livello 1, una
con input n ' = n e l'altra con input ri" = p n , dove n ' + n " ^ n. Quest'ultime due
chiamate contribuiscono per un totale di f ( n ' ) + f ( n " ) e inoltre invocano ulteriori due
chiamate ricorsive a testa, le quali costituiscono il livello 2 della ricorsione e ricevono in
input mo, m i , m.2 e m.3 t a h che mo + mi +m2+m.3 ^ n . Dovrebbe essere chiaro a questo
punto che queste chiamate contribuiscono per un totale di f ( m o ) + f ( m i )+f(m.2) + f(m3)
e inoltre invocano ulteriori due chiamate ricorsive a testa. La forma chiusa della soluzione
per l'equazione (2.9) data dalla somma dei termini noti

T(n) = f(n) + [f(n') + f(n")] + [f(mo) + f ^ ) + f(m 2 ) + f(m 3 )] +

la cui valutazione dipende dal tipo della funzione f(n): per esempio, abbiamo visto che
se f(n) = O(n), allora otteniamo T(n) = O ( n l o g n ) .

2.6 Opus libri: grafica e moltiplicazione di matrici


La definizione di sequenza lineare data all'inizio del capitolo pu essere estesa anche al
caso in cui consideriamo un'organizzazione degli elementi di un insieme su un array
A [0] [0] A[0] [1] A[0] [2] A[0] [3] A [0] [4]

A[l] [ 0 ] ACID [l] A[1] [ 2 ] A[1] [3] A [ l ] [4]

A [2] [0] A [ 2 ] [1] A[2] [2] A [2] [3] A [2] [4]

A [3] [0] A[3] [1] A [3] [2] A [3] [3] A [3] [4]

Figura 2.5 Un array bidimensionale o matrice A 4 x 5 .

bidimensionale, o matrice. Anche nel caso degli array bidimensionali, gli elementi della
sequenza sono conservati in locazioni contigue della memoria: a differenza degli array
monodimensionali, tali locazioni sono organizzate in due dimensioni, ovvero in righe
e colonne. Ogni riga contiene un numero di elementi pari al numero delle colonne
dell'array e l'elemento contenuto nella colonna j della riga i di un array A viene indicato
con A[i][j], Ad esempio, nella Figura 2.5, viene mostrato un array bidimensionale A di 4
righe e 5 colonne (indicato come A4 x 5 ) e, per ogni elemento, viene mostrata la notazione
con cui esso viene indicato. L'organizzazione bidimensionale delle locazioni di memoria
non modifica la principale propriet degli array, ovvero la possibilit di accedere in modo
diretto ai suoi elementi. 2
Un tipo particolarmente importante di matrici rappresentato dal caso in cui gli
elementi contenuti siano interi o reali: nate per rappresentare sistemi di equazioni lineari
nel calcolo scientifico, le matrici sono utilizzate, tra le altre cose, per risolvere problemi su
grafi e per classificare l'importanza delle pagine Web come vedremo nel seguito del libro.
In questo paragrafo, discutiamo la loro importanza nel campo della grafica al calcolatore
{computer graphics) e della visione artificiale. Una matrice A = A r x c pu modellare i
punti luminosi {pixel) in cui discretizzata un'immagine digitale contenuta nella memo-
ria video (frame buffer) avente risoluzione c x r pixel, dove 1024 x 768, 1280 x 1024,
1400 x 1050, 1600 x 1200 e 1920 x 1200 sono alcuni dei formati digitali standard (da
notare che, nella risoluzione del video, indichiamo prima il numero delle colonne c e poi
quello delle righe r). Il pixel che si trova in corrispondenza della riga i e della colonna j
rappresentato dall'elemento A[i][j], che specifica (direttamente o indirettamente) il co-

2
Un array bidimensionale A r><c di r righe e c colonne pu sempre essere visto come un array monodi-
mensionale b contenente r x c elementi: per ogni i e j con 0 < i < r e 0 i j < c , l'accesso all'elemento
A[il[j] corrisponde semplicemente all'accesso a b[i x c + j] in tempo 0 ( 1 ) . Sebbene in questo libro non ne
faremo mai uso, non difficile immaginare come sia possibile estendere il concetto di array a k dimensioni
con k > 2, cosi che l'accesso a un elemento dell'array avvenga sulla base di k indici in tempo O(k).
lore e la luminosit del pixel utilizzando uno certo numero di bit (bit depth, solitamente
pari a 24 o 32).
Quando usiamo un videogioco tridimensionale, stiamo pi o meno inconsapevol-
mente impiegando delle matrici nella rappresentazione delle scene che possiamo osser-
vare muovendoci all'interno dello spazio di gioco. In genere queste scene sono realizzate
mediante superfici composte da innumerevoli triangoli disposti nello spazio tridimen-
sionale, i cui vertici di coordinate (x,y, z) sono rappresentati mediante vettori o array di
quattro elementi [x, y, z, 1] (l'uso della quarta coordinata pari a 1 sar chiarito fra breve).
Per arrivare a mostrare sul frame buffer del video tali scene in forma digitale (rendering)
occorre che tutte le primitive geometriche che compongono ciascuna scena attraversino
diverse fasi di computazione:
1. una fase di trasformazione per scalare, ruotare e traslare le figure geometriche in
base alla vista attuale della scena;
2. una fase di illuminazione per calcolare quanta luce arrivi direttamente su ogni
vertice;
3. una fase di trasformazione per dare la prospettiva dell'occhio umano alla vista
attuale della scena;
4. una fase di ritaglio (clipping) per selezionare solo gli oggetti visibili nella vista
attuale;
5. una fase di proiezione della vista attuale in tre dimensioni in un piano bidimen-
sionale (equivalente a un'inquadratura ottica simulata con la grafica vettoriale);
6. una fase di resa digitale (rastering) per individuare quali pixel del frame buffer
siano infine coperti dalla primitiva appena proiettata.
La realizzazione efficiente di tali fasi di rendering richiede perizia programmativa e pro-
fonda conoscenza algoritmica e matematica (ebbene s, dobbiamo imparare molta ma-
tematica per programmare la grafica dei videogiochi) e si basa sull'impiego massiccio di
potenti e specializzate schede grafiche. In particolare, la fase 1 effettua la trasformazione
mediante operazioni di somma e prodotto di matrici, definite qui di seguito.
La somma A + B di due matrici A,- Xs e B r x s la matrice C r x s tale che C[i][j] =
A[i][j] + B[i][j] per ogni coppia di indici O ^ i ^ r l e O ^ j ^ s 1: ogni elemento della
matrice C quindi pari alla somma degli elementi di A e B nella medesima posizione.
Il prodotto A x B di due matrici A r x s e B s x t la matrice C r x t tale che C[i][j] =
A[i] [k] x B[k][j]) per ogni coppia di indici O ^ i ^ r I e 0 < j ^ t - 1 : notiamo
che, contrariamente al prodotto tra due interi o reali, tale prodotto non commutativo
mentre associativo. L'elemento C[i][j] anche detto prodotto scalare della riga i di A
per la colonna j di B.
Nella Figura 2.6 illustriamo come scalare, ruotare e traslare una figura mostrando, ai
fini della nostra discussione, tali operazioni solo per un punto bidimensionale [x,y, 1].
Per scalare di un fattore a lungo l'asse delle ascisse e di un fattore (3 lungo quello delle
Figura 2.6 Operazioni su matrici per scalare, ruotare e traslare un punto di ascissa x e ordinata y
nella fase 1 del rendering di un'immagine digitale.

ordinate, effettuiamo la moltiplicazione

a 0 0'
[x,y, 1] x 0 (3 0 = [ax, |3y, 1]
0 0 1
Per ruotare di un angolo i>, osserviamo che la nuova posizione [x',y', 1] soddisfa la
relazione trigonometrica x' = x c o s O y sin <D e y ' = x s i n + y cosO, per cui la
corrispondente moltiplicazione la seguente: 3

cos a> sin O 0'


(x,y, 1] x - sin > cos Q 0 [x',y',l]
0 0 1

'Se V l'angolo che il punto (x, y ) forma con l'asse delle x e T la sua distanza dall'origine (0,0), allora,
usando le ben note uguaglianze trigonometriche cos(<D + M') = cos<Dcosf sinOsin V e sin(<t> + 4*) =
sin O cosV+cos s i n f , otteniamo che x' = rcoslO+M 7 ) = rcos <D cosVrsin <D sinH' e y ' = r sin(C> -t-M7) =
rsinOcosH' + rcos <t> sin M7. Poich rcosM7 = x e rsin V = y, abbiamo che vale la relazione utilizzata per la
moltiplicazione.
Infine, la traslazione di una quantit A x sulle ascisse e di una quantit A y sulle ordinate,
necessita della dimensione fittizia per essere espressa come una moltiplicazione

0
[x,y, 1] x 1 = [x + A x , y + A y , 1]
^X Ay

Non difficile estendere le suddette trasformazioni al caso tridimensionale. Altre trasfor-


mazioni possono essere espresse mediante la moltiplicazione di opportune matrici come,
ad esempio, quella per rendere speculare una figura. Il vantaggio di esprimere tutte le
trasformazioni in termini di moltiplicazioni risiede nel fatto che cos esse possono essere
composte in qualunque modo mediante la moltiplicazione delle loro corrispettive ma-
trici. In altri termini, una qualunque sequenza di n trasformazioni applicate a un punto
[x,y,z, 1] possono essere viste come la moltiplicazione di quest'ultimo per le n matrici
A Q , A J , . . . , A N _ _ I che rappresentano le trasformazioni stesse:

[x,y,z, 1] x A 0 x A j x x A n _ i

Tuttavia, dovendo ripetere tale sequenza per tutti i vertici delle figure che si vogliono
trasformare pi efficiente calcolare la matrice A* = Ao x A[ x x A n _ i una sola volta
e quindi ripetere una singola moltiplicazione [x, y,z, 1] x A* per ogni vertice [x,y,z, 1]
di tali figure (quest'ultime moltiplicazioni sono eseguite in parallelo dalla scheda grafica
del calcolatore).
Secondo quanto discusso finora, le matrici coinvolte non hanno pi di quattro righe
e quattro colonne. Tuttavia, per raggiungere un certo grado di realismo nel processo di
rendering necessario simulare con buona approssimazione il comportamento della luce
in una scena, calcolando ad esempio ombre portate, riflessioni ed effetti di illuminazione
indiretta. Nel calcolo dell'illuminazione indiretta, dobbiamo determinare quanta luce
arrivi su di un punto, non direttamente da una sorgente luminosa come il sole, ma
riflessa dagli altri oggetti della scena.
Una delle soluzioni classiche a questo problema, noto come il calcolo della radiosit,
consiste nel calcolare una matrice M m x m di vaste dimensioni per una scena composta
da m primitive elementari, in cui l'elemento M[i][j] della matrice descrive in percentuale
quanta luce che rimbalza sulla superficie i arriva anche sulla superficie j.
Tralasciando come ottenere tali valori, partiamo da un array Lo di m elementi in cui
memorizziamo quanta luce esce da ognuna delle m primitive (ponendo a 0 gli elementi
per ogni primitiva eccetto che per le sorgenti luminose). Moltiplicando M per Lo si
ottiene un array L] in cui ogni elemento contiene l'illuminazione diretta della primitiva
corrispondente. Continuando a moltiplicare per M, otteniamo una serie di array Li
(dove i > 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 C; {post: C di taglia r x t)

Codice 2 . 1 6 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 (Para-
grafo 2.5.2), l'applicazione del paradigma del divide et impera permette di definire algo-
ritmi 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 b' e f' ae + bg af + bh'


X
c d .9 h. ce + dg cf + d h

Se introduciamo i seguenti valori

v0 = ( b - d ) ( g + H) v 4 = a(f - h)
vi = (a + d)(e + h.) v 5 = d(g - e)
v2 = (a c)(e + f) v 6 = e(c + d)
v3 = h ( a + b)

possiamo osservare che la moltiplicazione precedente pu essere espressa come

"a b' e f" V0 +Vi - v 3 +V5 v 3 + V4


x
C d .9 h. v5 + v6 VI - V2 +v4 - v 6

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:

se n ^ 2
T(n 2
(2.10)
7T(f) +c'n altrimenti

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[kl]+f ib[k2] 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 so-


pra, 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 )) d2d3d4 + did2d4 + dodid 4 = 1.200.000


(A 0 x ((A, x A 2 ) x A 3 ) did2d3 + did3d4 + d0did4 = 142.000
((A 0 x A I ) x (A 2 x A 3 ) ) d0did2 + d2d3d4 + d0d2d4 = 7.100.000
(((A0 X A , ) X A 2 ) X A3) d0did2 + d0d2d3 + d0d3d4 = 2.210.000
((AQ x (AI x A 2 )) x A 3 ) did2d3 + d0did3 + d0d3d4 = 54.000

Per esempio, la quarta riga corrisponde all'ordine naturale di moltiplicazione da sini-


stra 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 ef-
fettuate 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
cu
'Tale numero il numero di Catalan C ] = ^ ( l i '' ' 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 (2 11)


\ mini^T.<j { M ( i , r ) + M ( r + l , j ) + d i d T + i d j + i } altrimenti

Il calcolo della soluzione della regola in (2.11) richiama l'utilizzo del paradigma del
divide et impera e, di conseguenza, suggerisce la definizione di un algoritmo ricorsivo
per il calcolo di M(i, j), come mostrato nel Codice 2.17. La struttura dell'algoritmo
rispecchia quella della regola in (2.11): in particolare, il controllo alla riga 2 verifica se
siamo nel caso base in cui i ^ j, restituendo il valore 0. Altrimenti, il ciclo alle righe 4 -
9 determina, utilizzando le due chiamate ricorsive (righe 5 e 6), il costo minimo del
prodotto aggiornandolo ogni qualvolta trova un valore pi basso (riga 8): l'algoritmo
restituisce tale costo minimo nella riga 10.
Se analizziamo per con maggiore attenzione il comportamento dell'algoritmo, in
particolare dal punto di vista delle chiamate ricorsive effettuate, notiamo che esso pre-
senta una significativa replicazione di chiamate gi effettuate. Ad esempio, la Figura 2.7
illustra come i valori M ( 0 , 1 ) , M ( l , 2 ) e M ( 2 , 3 ) vengano tutti calcolati due volte distin-
te nel corso del calcolo di M ( 0 , 3 ) , mentre i valori M ( l , 1) e M ( 2 , 2 ) sono calcolati ben
M(0,0)

M(l,3)

M(0,0)

M(l, 1)

M(l,l)|
M(2,2) |

M(0,2) M(0,0)|

M(3,3) M(l,l)|

Figura 2.7 Chiamate ricorsive per il calcolo di M(0,3).

cinque volte. Usare la ricorsione in tale situazione estremamente inefficiente, al contra-


rio di quanto succede con il paradigma del divide et impera: il motivo di tale inefficienza
risiede nel fatto che le chiamate ricorsive dell'algoritmo nel Codice 2.17 calcolano ripe-
tutamente gli stessi valori, richiedendo cos un numero esponenziale fl(2n) di passi di
calcolo come mostriamo di seguito.
Indichiamo con T(n) il numero di passi richiesti dall'algoritmo ricorsivo nel Codi-
ce 2.17 per un'istanza di n matrici (usando i parametri iniziali i = 0 e j = n 1). Nel
caso base n ^ 1, la complessit una costante c > 0 (riga 2). Altrimenti, abbiamo una
complessit costante c' > 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

S
T(n)- , . , 1 , + T ( n _r_ 1 ) + C) J^j, t i (2.12)
, \ J o 1 2 3

0 0 2000000 44000 54000


1 - 0 40000 42000
2 - - 0 100000

3 - - - 0

Figura 2.8 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 " = 1, esprimendo la


complessit nel caso n > 1 dell'equazione (2.12) come

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 n1 n 2
T(n) = n + 2 ^ T ( k ) ^ n + 2 ^ 2k~ = n + 2 2T = n + 2 ( 2 n " ' - 1) >
k= 1 k=l 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 costi[0][n-1];

Codice 2.18 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 rispet-
to 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 518): 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 opera-
zione 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 ) : {pre: O ^ i ^ j ^ n 1)
IF (i == j) {
RETURN 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 par-
zialmente 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 defi-
nizione ricorsiva che correla la soluzione di un problema a quella di un insieme di sotto-
problemi: 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 (equa-
zione (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 otti-
mizzazione, 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 com-
porta 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 formula-
zione 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 Programmazione dinamica

Figura 2.9 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 decom-
posizioni 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 ve-
dere 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 solu-


zioni 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 mo-
dalit 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 corri-
spondono, rispettivamente, all'introduzione del sotto-problema per il costo ottimo del
prodotto di Ai,..., Aj, all'identificazione del caso i = j come sotto-problema elemen-
tare 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, memorizzan-
doli 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 In-
ternet, che pu permettere l'accesso remoto da qualunque parte del mondo. Uno dei
metodi usati consiste nell'individuare particolari sotto-sequenze che appaiono nelle se-
quenze 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 coman-
di 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 etichet-
te 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 ve-
rificare 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 sotto-
sequenza di F = B, A, A, B, D, C, D, C, A, A, C, A, C, B, A (dove le lettere sottolineate contras-
segnano 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 se-
quenze 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 ri-
spettivamente, 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 poli-
nomiale 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 rispettiva-
mente 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 no-
stro 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] (2.13)
{ max { L(i, j 1 ), L(i 1, j) } se i, j > 0 e a[i - 1] b[j - 1]
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 sotto-
sequenze 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 appa-
riranno 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 sotto-
sequenza comune pi lunga (righe 3 e 5):

StampaLCS ( i, j ) : (pre:
IF ((i > 0) && (j > 0)) {
< i \ j'> = indiceli] [j];
StampaLCS( i', j' );
IF ((i' == i-1) && (j' == j-1))) PRINT a[i-l];
>
LCS ( a, b ) : (pre: a e b sono di lunghezza min)
FOR ( i = 0; i <= M; i = i + 1 )
lunghezza[i][0] = 0 ;
FOR (j = 0; j <= n; j = j+1)
lunghezza[0][j] = 0 ;
FOR ( i = 1; i <= m; i = i+1)
FOR (j = 1; j <= n; j = j+1) {
IF (a[i-l] == b[j-l]) {
lunghezza[i][j] = lunghezza[i-1][j-1] + 1;
> ELSE IF (lunghezza[i][j-1] > lunghezza[i-1][j]) {
lunghezza[i][j] = lunghezza[i][j-1];
> ELSE {
lunghezza[i][j] = lunghezza[i-1][j];
>
>

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 descrit-


to 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 ta-
bella l u n g h e z z a per colonne e ne utilizzi solamente due colonne alla volta). Tutta-
via, 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, im-
plementato 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 nel-
le 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
T(i j) = ^ sei > 0 e T ( i - l , j ) = t r u e
' true se i > 0, j ^ a t _ i e T(i - l , j - a t _ i ) = t r u e
false altrimenti

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 <= n ; i = i+1)
FOR ( j = 0; j <= s ; j = j + 1 ) {
partiti][j] = FALSE;
>
partito][0] = TRUE;
FOR ( i = 1; i <= n ; i = i+1)
FOR ( j = 0; j <= s ; j = j + 1 ) {
IF ( p a r t i t i - 1 ] t j ] ) {
partiti]tj] = TRUE;
>
IF ( j >= a[i-L] &fe p a r t i t i ~ L ] t j - a [ i - L ] ] ) {
partiti]tj] = TRUE;
>
>

RETURN parti[n]ts];

Codice 2.20 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 (jQ_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 so-
luzione 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 an-
zitutto 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 dimensio-
ne opportuna per mantenere le soluzioni dei vari sotto-problemi considerati, prevedendo
inoltre che gli elementi di tale array possano assumere un valore "indefinito", che indi-
chiamo 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 <= s; j = j+1)
partito][j] = FALSE;
parti[0][0] = TRUE;
FOR (i = 1; i <= n; i = i+1)
FOR (j = 0; j <= s; j = j+1) {
partiti][j] = 0;
>
RETURN PartizioneRicNota( n, s );

PartizioneRicNota( i, j ) : (pre: O ^ i ^ n ,
IF (partiti][j] == 0) {
partiti]tj] = PaxtizioneRicNota( i-1, j );
IF (partiti][j] && (j >= a[i-l])) {
partiti]tj] = PartizioneRicNota( i-1, j-ati-1] );
}
>
RETURN partiti]tj];

2.7.3 Problema della bisaccia


Mostriamo ora una generalizzazione del problema della partizione a un famoso pro-
blema 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, co-
me 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 pos-
siamo 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 dispo-
nibili 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) pos-
sibilit, 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 <= n; i = i+1) {
FOR (j = 0 ; j <= possanza; j = j+1) {
VCi] [j] = 0;
>
>
FOR (i = 1; i <= n; i = i+1) {
FOR (j = 1; j <= possanza; j = j+1) {
V[i][j] = V[i-1] [j] ;
IF (j >= peso[i-1])) {
m = V[i-1][j-peso[i-l]] + valore[i-l];
IF (M > V[i] [j]) VCi] [j] = M;
>
>
>

RETURN V[n][possanza];

Codice 2.21 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 sotto-
problemi 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 otti-
ma 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 dimen-
sione 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 vie-
ne 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'al-
goritmo 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'ordi-
namento 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 co-


sto 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'algorit-
mo 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 man-


tiene la posizione relativa nella sequenza d'uscita (quindi gli elementi uguali ap-
paiono 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 Rnyi-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. Mostra-
te 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, mo-
tivandone 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:
"a b' e f 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 bisac-
cia. 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 genera-
le, 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 risie-
dono in locazioni di memoria contigue e, pertanto, ciascun dato deve includere, oltre
all'informazione vera e propria, un riferimento al dato successivo.
Qo ai a2 n-2 Qn-1

Figura 3.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'e-
lemento 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 vuo-
ta, 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 != nuli) && (j < i)) {
p = p.succ;
j = j+i;
>

3.1.1 Ricerca, inserimento e cancellazione


Dato che una lista rappresenta una diversa realizzazione, rispetto a un array, di una se-
quenza lineare di elementi, possiamo definire su di essa le operazioni di ricerca, inseri-
mento 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 dif-
ferenza 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 (Cp != nuli) kk (p.dato != k))
p = p.succ;
RETURN P;

Codice 3.1 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 riferimen-
ti. 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 riferi-


menti 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 efficien-
temente-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
I

Figura 3.3 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 cancel-
lazione, 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 ca-
so, non avendo un predecessore, la cancellazione determina la modifica del riferi-
mento 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 modifi-
ca 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 pen-
dente {dangling pointer).
In una lista circolare, l'ultimo elemento fa riferimento, come suo successore, al pri-
mo, 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 ele-
menti corrispondono ai programmi che necessitano della CPU, lista scandita da un rife-
rimento al programma attualmente in esecuzione. L'assegnazione della CPU a un altro
programma, in corrispondenza allo scadere del quanto di tempo, comporter quindi l'in-
dividuazione 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'in-
serimento 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 mo-
do, 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 succes-
sive. 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'ap-
proccio 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, oc-
corre 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 rifiu-
tano 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 chia-
meremo 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 pre-
cedenti 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 preferenze di d

0 E
0

Figura 3.7 Un esempio di non stabile.

3.2.1 Strutture di dati utilizzate


Per realizzare l'algoritmo, decidiamo quali strutture di dati adottare: a tale scopo os-
serviamo 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 riferimen-
to 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 pre-
ferenze: 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 costrui-
re 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.
MatrimoniStabili- (pre: vedi 3.2.1 per celibe, preferita, abbinato, rango)
WHILE (celibe != 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 attualmen-
te parte dell'abbinamento: supponiamo che i valori di a b b i n a t o siano inizializza-
ti a un valore "nullo", per convenzione pari a 1. Anche in questo caso necessitia-
mo 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 abbina-
to 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 34): 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 qua-
lunque 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 suffi-
ciente 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 momen-
to 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 osservazio-
ne, 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 clien-
ti 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 innu-
merevoli 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 ca-
so 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'al-
goritmo 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 18 + 0O

L[L] ' oo ' 10 18 -41 +00


'r
L[0] 00 5 10 16 18 30 41 80 +oo

Figura 3.8 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 possia-
mo 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'.
ScansioneSkipList ( k ) : (pre: gli elementi oo e +00 fungono da sentinelle)
p = L[h] ;
WHILE (p != n u l i ) {
WHILE (p.succ.key <= k)
p = p.succ;
predecessore = p;
p = p.inf;
}

RETURN predecessore;

Codice 3.3 Scansione di una lista a salti per la ricerca di una chiave k.

La ricerca di una chiave k concettualmente semplice. Ad esempio, supponiamo di


voler cercare la chiave 80 nella lista mostrata nella Figura 3.8. Partendo da I_2, troviamo
che il predecessore di 80 in L2 18: a questo punto, passiamo alla copia di 18 nella lista
Li e troviamo che il predecessore di 80 in quest'ultima lista 41. Passando alla copia
di 41 in Lo, troviamo il predecessore di 80 in questa lista, ovvero 80 stesso: pertanto, la
chiave stata trovata.
Tale modo di procedere mostrato nel Codice 3.3, in cui supponiamo che gli ele-
menti in ciascuna lista Lg abbiamo un riferimento i n f per raggiungere la corrispondente
copia nella lista inferiore Partiamo dalla lista Lh (riga 2) e troviamo il predecessore
Ph. di k in tale lista (righe 46). Poich Lh C L ^ - i , possiamo raggiungere la copia di
Ph in L h _ i (riga 7) e, a partire da questa posizione, scandire quest'ultima lista in avanti
per trovare il predecessore P h - i di k in L ^ - i - Ripetiamo questo procedimento per tutti
i livelli a decrescere: partendo dal predecessore p^ di k in L^, raggiungiamo la sua copia
in e percorriamo quest'ultima lista in avanti per trovare il predecessore p^ ] di k
in L/>_] (righe 38). Quando raggiungiamo Lo (ovvero, p uguale a n u l i nella riga 3),
la variabile p r e d e c e s s o r e memorizza po, che il predecessore che avremmo trovato se
avessimo scandito Lo dall'inizio di tale lista.
Il lettore attento avr certamente notato che l'algoritmo di ricerca realizzato dal Co-
dice 3.3 molto simile alla ricerca binaria descritta nel caso degli array (Paragrafo 2.4.1):
in effetti, ogni movimento seguendo il campo s u c c corrisponde a dimezzare la porzione
di sequenza su cui proseguire la ricerca. Per questo motivo, facile dimostrare che il
costo della ricerca effettuata dal Codice 3.3 O(logn) tempo, contrariamente al tempo
O(n) richiesto dalla scansione sequenziale di Lo-
Il problema sorge con l'operazione di inserimento, la cui realizzazione ricalca l'al-
goritmo di ricerca. Una volta trovata la posizione in cui inserire la nuova chiave, per,
l'inserimento vero e proprio risulterebbe essere troppo costoso se volessimo continuare
a mantenere le propriet della lista a salti descritte in precedenza, in quanto questo po-
trebbe voler dire modificare le copie di tutti gli elementi che seguono la chiave appena
inserita. Per far fronte a questo problema, usiamo la casualit: il risultato sar un algorit-
mo random di inserimento nella lista a salti che non garantisce la struttura perfettamente
bilanciata della lista stessa, ma che con alta probabilit continua a mantenere un'altezza
media logaritmica e un tempo medio di esecuzione di una ricerca anch'esso logaritmico.
Notiamo che, senza perdita di generalit, la casualit pu essere vista come l'esito di
una sequenza di lanci di una moneta equiprobabile, dove ciascun lancio ha una possibilit
su due che esca testa (codificata con 1) e una possibilit su due che esca croce (codificata
con 0). Precisamente, diremo che la probabilit di ottenere 1 q = j e la probabilit di
ottenere 0 1 q = j (in generale, un truffaldino potrebbe darci una moneta per cui

Attraverso una sequenza di b lanci, possiamo ottenere una sequenza random di b bit
casuali. 2 Ciascun lancio nella pratica simulato mediante la chiamata a una primitiva
random() per generare un valore reale r pseudocasuale appartenente all'intervallo 0 ^
r < 1, in modo uniforme ed equiprobabile (tale generatore disponibile in molte librerie
per la programmazione e non semplice ottenerne uno statisticamente significativo, in
quanto il programma che lo genera deterministico): il numero r generato pseudo-
casualmente fornisce quindi il bit 0 se 0 ^ r < j e il bit 1 se 5 ^ r < 1.
Osserviamo che i lanci di moneta sono eseguiti in modo indipendente, per cui otte-
niamo una delle quattro possibili sequenze di b = 2 bit (00, 01, 10 oppure 11) in modo
casuale, con probabilit ^ x \ = in generale, le probabilit dei lanci si moltiplicano in
quanto sono eventi indipendenti, ottenendo una sequenza di b bit casuali con probabi-
lit l / 2 b . Osserviamo inoltre che prima o poi dobbiamo incontrare un 1 nella sequenza
se b sufficientemente grande.
Utilizziamo tale concetto di casualit per inserire una chiave k in una lista a salti.
Una volta identificati i suoi predecessori P0,Pi, ,Pk> 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 incom-
pressibilit, 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 descrizio-
ne. 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 nume-
ro 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 ge-
nerale, 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 Codi-
ce 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 Codi-
ce 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 disugua-


glianza con l'uguaglianza nell'equazione (3.1), analogamente a quanto discusso nel Pa-
ragrafo 2.5.4. Moltiplicando i termini dell'equazione cos trasformata per 2 e risolvendo
rispetto a T(^) otteniamo
1{) =T(l- 1) + 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 ac-
corto 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 co-
me 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'o-
perazione 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, mi-
gliorando 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 lunghez-
za. 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 28): 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 1415) 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 genera-
le, 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 pri-
me in quanto le seconde richiedono tempo costante. Partendo da m elementi, ciascuno
Crea( x ) : (pre: x non vuoto)
lista.inizio = lista.fine = x;
lista.lunghezza = 1;
x.lista = lista;
x.succ = nuli;

Appartieni( x, y ): {pre: x, y non vuoti)


RETURN (x.lista == y.lista);

UnisciC x, y ): (pre: x,y non vuoti e x.. lista ^ y. lista)


IF (X.lista.lunghezza <= y.lista.lunghezza) {
corta = x.lista;
lunga = y.lista;
> 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'operazio-
ne 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 appartie-
ne 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 dop-
pia 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 ope-
razioni 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: ipo-
tizzando 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 in-
siemi 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) vol-
te. 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

<P>
Osserva, sperimenta e verifica o

UnionFind
CD
a? o

3.4.2 Liste ad auto-organizzazione


L'auto-organizzazione delle liste utile quando, per svariati motivi, la lista non ne-
cessariamente ordinata in base alle chiavi di ricerca (contrariamente al caso delle liste
randomizzate del Paragrafo 3.3). Per semplificare la discussione, consideriamo il so-
lo 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 verifican-
do iterativamente se l'elemento attuale uguale alla chiave cercata. Estendiamo tale
schema per eseguire eventuali operazioni di auto-organizzazione al termine della scan-
sione 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 tem-


porale, 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 effica-
ce 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. Osser-
viamo 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 confron-
tate 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 scan-
diti 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 == e
4 22 26 eo Ci 23 27 25
Lista PRM == e
e0 ei 4 25 26 27

Figura 3.9 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,

n1 n-1
C e c
costo(MTF) = costo(PRM) = j (3-3)
j=0 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 + (2) (3-4)
j=0 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 permuta-
zione, 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 ap-
pena 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 an-
che 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 necessa-
riamente 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 <Dj, in modo da dimostrare la
seguente relazione (introducendo un valore fittizio O ^ i che specifichiamo in seguito):

Cj + O j - O j . , < 2 c ( (3.5)

Possiamo derivare la (3.4) dalla (3.5) in quanto quest'ultima implica che

n 1 n1

j=0 j=0

1 termini O nella sommatoria alla sinistra della precedente diseguaglianza formano una
cosiddetta somma telescopica, (<D0 - ^ - ) + - + (^2 - H 1- ( ^ n - i -
<t>n_2) = ^ n - i ~ il> i, nella quale le coppie di termini di segno opposto si elidono
algebricamente: da questa osservazione segue immediatamente che

n-l n-1
n_i-<l>_i + ^ c j < 2 ^ c ( (3.6)
j=0 i =0

Ponendo <D_i = m | r ^ + 1 ) , vale <5_i - > n _i = 0 ( m 2 ) : portando a destra del segno di


diseguaglianza i primi due addendi nella (3.6), otteniamo cos la disuguaglianza (3.4).
Possiamo quindi concentrarci sulla dimostrazione dell'equazione (3.5), dove il caso
11
j = 0 vale per sostituzione del valore fissato per 0 _ i , in quanto <Do ^ e Co < m.
Ipotizziamo quindi che l'operazione j > 0 sia stata eseguita: a tale scopo, sia k l'elemento
acceduto in seguito a tale operazione, e supponiamo che k occupi la posizione i nella lista
di MTF -(per cui Cj = v): notiamo che la (3.5) banalmente soddisfatta quando i = 0
perch la lista di MTF non cambia e, quindi, Oj = <Dj_i. Prendiamo l'elemento k' che
appare in una generica posizione i ' < i. Ci sono solo due possibilit se esaminiamo
l'insieme {k', k}: un'inversione oppure non lo . Quando MTF pone k in cima alla
lista, tale insieme diventa un'inversione se e solo se non lo era prima: nel nostro esempio,
se k = (per cui i = 5), possiamo riscontrare che, considerando gli elementi k ' in
posizione da 0 a 4, due di essi, e4 e e^, formano (assieme a un'inversione mentre i
rimanenti tre elementi non danno luogo a inversioni. Q u a n d o viene posto in cima alla
lista di MTF, abbiamo che gli insiemi {e3, e4} e e<$} non sono pi inversioni, mentre
lo diventano gli insiemi (e2,63}, (eo, e-}) e {e\,
In generale, gli i elementi che precedono k nella lista di MTF sono composti da
f elementi che (assieme a k) danno luogo a inversioni e da g elementi che non danno
luogo a inversioni, dove f + g = i. Dopo che MTF pone k in cima alla sua lista, il
numero di inversioni che cambiano sono esclusivamente quelle che coinvolgono k. In
particolare, le f inversioni non sono pi tali mentre appaiono g nuove inversioni, come
illustrato nel nostro esempio. Di conseguenza la differenza nel numero di inversioni
dopo l'operazione j <Dj 1 = f + g. Ne deriva che cj -(- <J>j j - i = i f + g =
(f + g ) - f + g = 2g.
Consideriamo ora la posizione Cj dell'elemento k nella lista di PRM: sappiamo certa-
mente che e- > g perch ci sono almeno g elementi che precedono k, in quanto appaiono
prima di k anche nella lista di MTF e non formano con k inversioni prima dell'operazio-
ne j. A questo punto, otteniamo l'equazione (3.5), in quanto Cj+OjOj 1 = 2g ^ 2c-,
concludendo di fatto l'analisi ammortizzata.

ALVIE: liste ad auto-organizzazione

Osserva, sperimenta e verifica


MoveToFront

Osserviamo che tale analisi della strategia MTF sfrutta la condizione che l'algorit-
mo PRM non pu manipolare la lista una volta che abbia iniziato a gestire le richieste.
Purtroppo questa condizione necessaria e la precedente analisi ammortizzata non pi
valida se permettiamo anche all'algoritmo fuori linea di manipolare la sua lista: acce-
dendo all'elemento in posizione i, l'algoritmo pu ad esempio riorganizzare la lista in
tempo O(i) (pensiamo a un impiegato con la sua pila disordinata di pratiche: pescata la
pratica in posizione i, pu metterla in cima alla pila ribaltando l'ordine delle prime i nel
contempo). In particolare, possibile dimostrare che un algoritmo fuori linea che adotta
tale strategia, denominato REV, ha un costo pari a O ( n l o g n ) mentre il costo di MTF
risulta essere S ( n 2 ) , invalidando l'equazione (3.4) per n sufficientemente grande.
Tuttavia, MTF rimane una strategia vincente per organizzare le proprie informazioni.
L'economista giapponese Noguchi Yukio ha scritto diversi libri di successo sull'organizza-
zione aziendale e, tra i metodi per l'archiviazione cartacea, ne suggerisce uno particolar-
mente efficace. Il metodo consiste nel mettere l'oggetto dell'archiviazione (un articolo,
il passaporto, le schede telefoniche e cos via) in una busta di carta etichettata. Le buste
sono mantenute in un ripiano lungo lo scaffale e le nuove buste vengono aggiunte in
cima. Quando una busta viene presa in una qualche posizione del ripiano, identificata
scandendolo dalla cima, viene successivamente riposta in cima dopo l'uso. Nel momento
in cui il ripiano pieno, un certo quantitivo di buste nel fondo viene trasferito in un'op-
portuna sede, per esempio una scatola di cartone etichettata in modo da identificarne il
contenuto. Noguchi sostiene che pi facile ricordare l'ordine temporale dell'uso degli
oggetti archiviati piuttosto che la loro classificazione in base al contenuto, per cui il me-
todo proposto permette di recuperare velocemente tali oggetti dallo scaffale. Possiamo
facilmente riconoscere la strategia MTF nell'ordine ottenuto dal metodo di Noguchi, in
base alla frequenza d'uso.
In conclusione, le liste ad auto-organizzazione presentano una serie di vantaggi, in
quanto hanno buone prestazioni sotto certe condizioni, sono adattive rispetto alla distri-
buzione delle richieste, possiedono semplici algoritmi di manipolazione e, infine, non
necessitano di informazioni ausiliarie per la gestione (a parte i puntatori di lista). Co-
me ogni altra struttura, tuttavia, presentano anche alcuni svantaggi, poich il costo della
singola operazione al caso pessimo pu essere lineare e, inoltre, ogni ricerca comporta
comunque una ristrutturazione della lista.

3.4.3 Tecniche di analisi ammortizzata


Le operazioni di unione e appartenenza su liste disgiunte e quelle di ricerca in liste ad
auto-organizzazione non sono i primi due esempi di algoritmi in cui abbiamo applicato
l'analisi ammortizzata. Abbiamo gi incontrato implicitamente un terzo esempio di tale
analisi per valutare il costo delle operazioni di ridimensionamento di un array di lun-
ghezza variabile (Paragrafo 2.1.3). Questi tre esempi illustrano tre diffuse modalit di
analisi ammortizzata di cui diamo una descrizione utilizzando come motivo conduttore
il problema dell'incremento di un contatore.
In tale problema, abbiamo un contatore binario di k cifre binarie, memorizzate in
un array c o n t a t o r e di dimensione k i cui elementi valgono 0 oppure 1. In particolare,
il valore del contatore dato da ( c o n t a t o r e [ i ] x 2V) e supponiamo che esso
contenga tutti 0 inizialmente.
Come mostrato nel Codice 3.6, l'operazione di incremento richiede un costo in tem-
po pari al numero di elementi cambiati in c o n t a t o r e (righe 4 e 7), e quindi O(k) tempo
al caso pessimo: discutiamo tre modi di analisi per dimostrare che il costo ammortizzato
di una sequenza di n = 2 k incrementi soltanto 0 ( 1 ) per incremento.
Il primo metodo quello di aggregazione: conteggiamo il numero totale T(n) di pas-
si elementari eseguiti e lo dividiamo per il numero n di operazioni effettuate. Nel nostro
caso, conteggiamo il numero di elementi cambiati in c o n t a t o r e (righe 4 e 7), suppo-
nendo che quest'ultimo assuma il valore iniziale pari a zero. Effettuando n incrementi,
osserviamo che l'elemento c o n t a t o r e [ 0 ] cambia (da 0 a 1 o viceversa) a ogni incre-
Incrementa( contatore ): (pre: V. la dimensione di contatore)
i = 0;
WHILE ((i < k ) && (contatore[i] == 1) ) {
contatore[i] = 0;
i = i+1;
>
IF (i < k) contatore[i] = 1;

Codice 3.6 Incremento di un contatore binario.

mento, quindi n volte; il valore di c o n t a t o r e [ l ] cambia ogni due incrementi, quindi


n / 2 volte; in generale, il valore di c o n t a t o r e [ i ] cambia ogni 2 l incrementi e quindi
n / 2 1 volte. In totale, il numero di passi T(n) = i = 0 n / 2 1 = ( i = 0 1/2 1 ) < 2n.
Quindi il costo ammortizzato per incremento 0 ( 1 ) poich T ( n ) / n < 2. Osserviamo
che abbiamo impiegato il metodo di aggregazione per analizzare il costo dell'operazione
di unione di liste digiunte.
Il secondo metodo basato sul concetto di credito (con relativa metafora bancaria):
utilizziamo un fondo comune, in cui depositiamo crediti o li preleviamo, con il vinco-
lo che il fondo non deve andare mai in rosso (prelevando pi crediti di quanti siano
effettivamente disponibili). Le operazioni possono sia depositare crediti nel fondo che
prelevarne senza mai andare in rosso per coprire il proprio costo computazionale: il costo
ammortizzato per ciascuna operazione il numero di crediti depositati da essa. Osser-
viamo che tali operazioni di deposito e prelievo di crediti sono introdotte solo ai fini
dell'analisi, senza effettivamente essere realizzate nel codice dell'algoritmo cosi analizza-
to. Nel nostro esempio del contatore, partiamo da un contatore nullo e utilizziamo un
fondo comune pari a zero. Con riferimento al Codice 3.6, per ogni incremento eseguito
gestiamo i crediti come segue:

1. preleviamo un credito per ogni valore di c o n t a t o r e [ i ] cambiato da 1 a 0 nella


riga 4;

2. depositiamo un credito quando c o n t a t o r e [ i ] cambia da 0 a 1 nella riga 7.

Da notare che la situazione al punto 1 pu occorrere un numero variabile di volte du-


rante un singolo incremento (dipende da quanti valori pari a 1 sono esaminati dal ciclo);
invece, la situazione al punto 2 occorre al pi una volta, lasciando un credito per quando
quel valore da 1 torner a essere 0: in altre parole, ogni volta che necessitiamo di un cre-
dito nel punto 1, possiamo prelevare dal fondo in quanto tale credito stato sicuramente
depositato da un precedente incremento nel punto 2. Quindi il costo ammortizzato per
incremento 0 ( 1 ) . Possiamo applicare il metodo dei crediti per l'analisi ammortizzata
del ridimensionamento di un array a lunghezza variabile: ogni qualvolta che estendia-
mo l'array di un elemento in fondo, depositiamo c crediti per una certa costante c > 0
(di cui uno l'utilizziamo subito); ogni volta che raddoppiamo la dimensione dell'array,
ricopiando gli elementi, utilizziamo i crediti accumulati fino a quel momento.
Infine, il terzo metodo basato sul concetto di potenziale (con relativa metafora fisi-
ca). Numerando le n operazioni da 0 a n 1, indichiamo con 0 _ i il potenziale iniziale e
con <Dj ^ 0 quello raggiunto dopo l'operazione j, dove 0 ^ j ^ n 1. La difficolt con-
siste nello scegliere l'opportuna funzione come potenziale <D, in modo che la risultante
analisi sia la migliore possibile. Indicando con Cj il costo richiesto dall'operazione j, il
costo ammortizzato di quest'ultima definito in termini della differenza di potenziale,
nel modo seguente:
Cj = C j - c D j _ , (3.7)

Quindi, il costo totale che ne deriva dato da ^ " J o = (ci + ~~ =


1 1
^ j ^ Cj + ( n _ i ): utilizzando il fatto che otteniamo una somma telescopica per
le differenze di potenziale, deriviamo che il costo totale per la sequenza di n operazioni
pu essere espresso in termini del costo ammortizzato nel modo seguente:
n 1 n 1
Cj = ^ c j + (01-cDn_1) (3.8)
j=0 j=0
Nell'esempio del contatore binario, poniamo uguale al numero di valori pari a 1 in
c o n t a t o r e dopo il (j + l)-esimo incremento, dove 0 ^ j ^ n 1: quindi, O i = 0
in quanto il contatore inizialmente pari a tutti 0. Per semplicit, ipotizziamo che il
contatore contenga sempre uno 0 e, fissato il (j + 1 )-esimo incremento, indichiamo con
il numero di volte che viene eseguita la riga 4 nel ciclo w h i l e del Codice 3.6: il costo
quindi Cj = + 1 in quanto valori pari a 1 diventano 0 e un valore pari a 0 diventa 1.
Inoltre, la differenza di potenziale j j - i misura quanti 1 sono cambiati: ne abbiamo
in meno e 1 in pi, per cui > 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'operazio-
ne 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 an-


zich 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 configura-


zioni 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'ele-
mento 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 suc-
cessivo 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 ri-
chiesti 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 cre-


diti 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 ele-
mento 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 cia-
scuno. Ad esempio, nella Figura 4.1, mostrata una parte dell'albero genealogico della
famiglia Baggins di Hobbiville, quella relativa ai discendenti di Largo (corrispondente al-
la 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. Nel-
la 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, eti-
chettati 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 riferi-
mento 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 espli-
citamente disegnati) mostriamo come viene rappresentata la parte superiore dell'albero
dato sx dx px

Frodo
Baggins
/ /

Ti
V

Primula
Brandibuck / J

Ruby
Bolgeri / \

Figura 4.3 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 pa-
rametro, 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 ge-
nerale, 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, pren-
dendone 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 com-
posto 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'al-
tezza 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;
} (post: restituisce 1 se e solo se u nuli)

Codice 4.2 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 Co-
dice 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

o
Osserva, sperimenta e verifica
BinaryTreeHeight

Non difficile applicare lo schema ricorsivo appena delineato al calcolo del nu-
mero 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 de-
componibili, 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 valo-
ri 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'ele-
mento contenuto nel nodo-, visita ricorsivamente il sottoalbero destro.
Visita posticipata (postorder): visita ricorsivamente il sottoalbero sinistro; visita ricorsi-
vamente 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 es-
sere 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, con-
sideriamo 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 mo-
do di effettuare una visita posticipata in cui viene raccolta l'informazione necessaria alla
anticipata:
FB D B RB PB GB M B A B M T G T

simmetrica:
D B RB FB M B G B A B P B MT G T

posticipata:
R B D B M B A B G B G T M T PB FB

ampiezza:
F B D B PB RB G B M T M B A B G T

Figura 4.4 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 alcu-


ni 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 completa-
mente 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 pur-
troppo 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 <TRUE, ~1>;
> ELSE {
<bilSX,altSX> = CompletamenteBilanciatoC u.sx );
<bilDX,altDX> = CompletamenteBilanciatoC u.dx );
completamenteBil = bilSX && bilDX && CaltSX == altDX);
altezza = maxCaltSX, altDX) + 1;
RETURN <completamenteBil,altezza;
} [post: restituisce T R U E come prima componente - T(u) completamente bilanciato)

Codice 4.5 Algoritmo ricorsivo per stabilire se un albero binario completamente bilanciato.

La prima componente di D e c o m p o n i b i l e ( u ) TRUE se e solo se lo sono le prime


componenti di D e c o m p o n i b i l e f u s ) e di D e c o m p o n i b i l e ( u D ) e se le seconde
componenti sono uguali (riga 7).
La seconda componente di D e c o m p o n i b i l e ( u ) uguale al massimo tra le secon-
de componenti di D e c o m p o n i b i l e f u s ) e di D e c o m p o n i b i l e f u o ) incrementa-
to di 1 (riga 8)

ALVIE: albero binario c o m p l e t a m e n t e bilanciato

Osserva, sperimenta e verifica Cjr-:



FullyBalancedTree

Per completare il quadro dello schema generale riportato nel Codice 4.3, discutiamo
un algoritmo ricorsivo su alberi binari, in cui le chiamate non solo raccolgono informa-
zione dai sottoalberi, ma propagano simultaneamente informazione proveniente dagli
antenati, passando opportuni parametri alle chiamate. Un problema di questo tipo ri-
guarda l'identificazione dei nodi cardine. Dato un nodo u , sia p u la sua profondit e
h. u l'altezza di T(u). Diciamo che u un nodo cardine se e solo se p u = b u . Vogliamo
progettare un algoritmo ricorsivo che stampi il contenuto di tutti i nodi cardine presenti
in un albero binario.
In questo caso, possiamo presumere che D e c o m p o n i b i l e ( u ) = h u , analogamente
al Codice 4.1: tuttavia, al momento di invocare la chiamata ricorsiva su u dobbiamo
garantire di passare p u come parametro. Il Codice 4.6 ha quindi due parametri in in-
gresso per questo scopo: il primo indica il nodo corrente e il secondo la sua profondit.
Cardine ( u, p ) : (pre: p ia profondit di u)
IF (u == nuli) {
RETURN -1 ;
> 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;
} (post: stampa i nodi cardine di T(u))

Codice 4.6 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 (p == nuli) {
r = z;
> ELSE IF (u == p.sx) {
p.sx = z;
> ELSE {
p.dx = 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, ipo-


tizzando di avere anche il riferimento r alla radice dell'albero binario. Ai fini della di-
scussione, 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 24, 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 subisco-
no 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 ri-
chieste 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 rife-
rimento 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 u.padre;

Codice 4.9 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 28). A questo punto, abbiamo due possibilit:
1. abbiamo gi raggiunto il minimo antenato comune, in quanto un nodo era di-
scendente 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 ri-
chieste di ricerca del minimo antenato comune. Ad esempio, un tale scenario si pre-
senta 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 han-
no 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 va-
ste 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 ) : {pre: p la profondit di u)
IF (u != nuli) {
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 cal-
colo 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 An-
cestor) e risulta essere uno strumento fondamentale per risolvere altri problemi, come
vedremo in alcuni dei capitoli successivi. Il problema LCA introdotto nel modo se-
guente: 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 ri-
chiede 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 proble-
ma 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 compu-
tazionale n 2 probabilmente una delle pi comunemente utilizzate per progettare algo-
ritmi di risoluzione. Intuitivamente, tale tecnica consiste nel mostrare come le soluzioni
\ 1 /
Marmadoc Adaldrida Gerontius
Brandibuck Bolgeri Tue

Figura 4 . 5 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 (dal-
l'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 mo-
strato 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 vi-
sitato, 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, a> o
1 2 2 ^
Q B! Q UH

0 1 2 1 0 1 2 3 2 3 2 1 2 3 2 1 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16

Figura 4.6 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 costan-
te 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 Query per il problema RMQ.

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
2 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
21 0 1 3 4 4 5 6 8 8 10 11 11 12 14 15 16
22 0 4 4 4 4 5 6 8 11 11 11 11 15 16
23 0 4 4 4 4 5 11 11 11 16
24 0 4

Figura 4 . 7 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 prepro-
cessing 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 corrispon-
denza 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

<2E>

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 strut-
turati. I documenti in formato XML descrivono un albero i cui nodi sono etichettati
<genealogico>
<persona nome"Frodo Baggins">
<padre>
<persona nome="Drogo Baggins">
<padre>

</padre>
<madre>
<persona nome="Ruby Bolgeri">
<padre>

</padre>
<madre>

</madre>
</persona>
</madre>
</persona>
</padre>
<madre>
<persona nome="Primula Brandibuck">
<padre>

</padre>
<madre>

</madre>
</persona>
</madre>
</persona>
</genealogico>

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 verbo-
sit: 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 usan-
do 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 caratteristi-
ca 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 indipendente-
mente dal nodo u di appartenenza e che il contenuto di u . d a t o sia gi fornito com-
presso. 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 asso-
ciato 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 rappresentazio-
ne 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 rappresentazio-
ni, per cui cerchiamo di quantificarne il vantaggio. Ipotizzando che ciascun riferimen-
to ( 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 codifi-
cata 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 varia-
bili locali. L'albero binario completo a sinistra l'esempio principe di albero rappresen-
tabile 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 2 3 4 5 6 7 8 9
Ts X Y


FB D B PB FB RB G B LB

I-
1
sx
dx

Figura 4.9 Rappresentazione implicita di un 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 de-
stra. Dopo aver memorizzato la radice dell'albero (righe 34), 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), ag-
giungiamo 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. Quin-
di, 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 bina-
rio completo a sinistra in un array: osserviamo che i soli campi u . d a t o sono effettiva-
mente 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 posi-
zione 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 <= ultimo) {
u = nodo[attuale];
IF (u.sx != nuli) {
nodo[ultimo+1] = u.sx;
ultimo = ultimo +1;
>
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 tem-


po 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 me-
morizzazione 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 sini-
stra 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 "<s>
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 10000
nodo[0,n-l].dato pieno[0,2n]

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

4.3.2 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] memo-
rizza 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 corrisponden-
za, 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 convin-
cerci 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 so-
no 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 <= ultimoNodo) {
u = nodo[attuale];
IF (u.sx != nuli) {
nodo[ultimoNodo+1] = u.sx;
ultimoNodo = ultimoNodo + 1;
pieno[ultimoPieno + 1 ] = 1 ;
> 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, mante-
nendo 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
nodo pieno

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 sini-
stro 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] nel-
l'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, dob-
biamo conteggiare i 2 n + 1 bit necessari per memorizzare l'array p i e n o , e lo spa-
zio 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 ag-
giuntivi: 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] 1 1 1 0 1 0 0 1 1 0 0 0 0 0 0 0
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Rank 1 2 3 3 4 4 4 5 6 6 6 6 6 6 6 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'occu-


pazione 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] 3 5 6 6
j 1 2 3 4 ...
b[i] 1 1 1 0 1 0 0 1 1 0 0 0 0 0 0 0
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Rank 1 2 3 3 4 4 4 5 6 6 6 6 6 6 6 6

Se dobbiamo restituire uno dei valori in r ' , possiamo chiaramente farlo in tempo co-
stante. 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] 3 5 6 6
j 1 2 3 4
b'[j] 14 9 8 0
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Rank 1 2 3 3 4 4 4 5 6 6 6 6 6 6 6 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 oO O
O 'O O
. o
O O' O
'
O O OO OO O. O O. O O O O I O '
H= 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1
h= 1 0 0 0 0 1 1 1 1 1 1 1 1 2 2 2 2
h = 2 0 0 1 1 1 1 2 2 1 1 2 2 2 2 3 3
h= 3 0 1 1 2 1 2 2 3 1 2 2 3 2 3 3 4

Figura 4.12 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" 5 6
r' 3 5 1 1
Rank 1 2 3 3 4 4 4 5 6 6 6 6 6 6 6 6
b 1 1 1 0 1 0 0 1 1 0 0 0 0 0 0 0
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 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

<s>
Osserva, sperimenta e verifica
cE> <E>
BreadthRepresentation
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'o-
biettivo 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 (equivalen-
temente, 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 x Cn_s_,) (dove Co = Ci = 1) (4.1)
s=0
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 ricorren-
za (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^ _ (2n)! 2n 2 n x (2n - 1 ) x (2n 2 ) x - - - x 3 x 2 x l


n / 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(n1) 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. Conclu-
diamo 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 bi-
nari, 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 memo-
rizza 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 me-
morizzazione 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 mostra-
to 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 riferi-
menti 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 ordi-
nali: 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 sequen-
ze di parentesi bilanciate, come mostrato nella parte inferiore della Figura 4.15, utiliz-
zando il Codice 4.16. La regola utilizzata ricorsiva: ogni nodo u (di grado d) associa-
to 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 seguen-
te (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'ul-
time 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 1 oo 1 oo ,

nodo parentesi
X
0 1 2
n
3 4 5 6 7
rn n
8 9 10 11 12 13
( ( ( ) (J ( ) ) ( ) ) 1 ( 1 ) 1 )
FBDB - RbXy Zy PB

Figura 4.15 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 rappresen-
tazione 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 rappresen-


tare 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 in-
vece 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 array a di n elementi, progettate un algoritmo che costruisca ricorsi-


vamente, e in tempo 0 ( n ) , un albero binario bilanciato tale che a[i] sia l'(i +
l)-esimo campo u . d a t o in ordine di visita anticipata. Considerate anche gli
algoritmi 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 no-
di 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'implementa-
zione 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, inol-
tre, 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): InserisciFondo(lista, e):
p = NuovoNodo( ); p = NuovoNodo( );
p.dato = e; p.dato = e;
lun = lista.lunghezza; lun = lista.lunghezza;
IF (lun == 0) { IF (lun == 0) {
p.succ = p.pred = nuli; p.succ = p.pred = nuli;
lista.inizio = p; lista.inizio = p;
lista.fine = p; lista.fine = p;
> ELSE { > ELSE {
p.succ = lista.inizio ; p.succ = nuli;
p.pred = nuli; p.pred = lista.fine;
lista.inizio.pred = p; lista.fine.succ = p;
lista.inizio = 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 opera-
zioni 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 crittogra-
fico 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 logi-
che 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 riceve-
rebbero 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. Ta-
le 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 inter-
rogazione, 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 diziona-
rio 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 inizialmen-
te 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 richiedo-
no 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) con-
fronti 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 per-
messa 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^ Osserva, sperimenta e verifica


ChainingHash

5.3.2 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] != e);

Codice 5.4 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, os-
serviamo 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 equipro-
babili): 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'ac-
cesso alla posizione occupata). Facendo la media pesata di tali costi (abbiamo gi in-
contrato 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:

(5>1)
T(n,m) = | | ^ i j ( n - l , m 1) linimenti

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 T u - l,m- 1 < 1H x =
m 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 po-
sizioni 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 appa-
iono 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 dispo-
sizione 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 (utiliz-
zando 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 pre-
senti 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 princi-
pale 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 fa-
cendo 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 (precisa-
mente, 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 del-
l'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; {post: se k appare gi in u, non viene memorizzata)

C o d i c e 5.5 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 35), 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 quan-
to 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 riferi-
mento a essa oppure, se il nodo ha un solo figlio, creiamo un "ponte" tra il padre di u e
il figlio di li-
Quando u. ha due figli non possiamo cancellarlo fisicamente (righe 9-11), ma dob-
biamo 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 can-
cellazione 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 comples-
sit 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'al-
tezza 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'es-
sere 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\ Fib-i Fib FibH

7
' A

Figura 5.1 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<^_ \ oppure a F i b ^ - i per costruzio-
ne. Ci impossibile in quanto questi ultimi sono minimali per ipotesi induttiva e, se
Fibh-\ cambiasse altezza, anche Fib^ la cambiarebbe, mentre se H ^ h - 2 cambiasse altez-
za, Fibh non sarebbe pi 1-bilanciato. Quindi, anche FibH minimale e concludiamo
che ogni albero 1-bilanciato di altezza h. con n nodi soddisfa n ^
Tabulando i primi 15 valori di n ^ e altrettanti numeri di Fibonacci Fh, possiamo
verificare per ispezione diretta che vale la relazione rih = Fh+3 1:

h 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 2 4 7 12 20 33 54 88 143 232 376 609 986 1596
Fh 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

Utilizzando la nota forma chiusa dei numeri di Fibonacci

F ^ - ^ , dovecJ> = ! ^ l , 6180339...
v5 2

possiamo affermare che FH > ^ ^ e


che, quindi, esiste una costante c > 1 tale che
H
FH > c 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 ): NuovaFogliaC e ):
IF (u == nuli) { u = NuovoNodoO;
RETURN -1; u.dato = e;
> ELSE { u.altezza = 0;
RETURN u.altezza; 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) i;
v.altezza = max( Altezza(v.sx), Altezza(v.dx) 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, uti-
lizzando 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 so-
no 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 (indivi-
duati 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 crtico 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 configurazio-
ne 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 no-
do 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>
cwiT crnrn
Osserva, sperimenta e verifica CjTTTp (TT7TT)
Ctna><EII>CIIi&CE3)
OUI!> OTTTT)t
cTTTTTtcrnT
dmr>
Avllnsert

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'albe-
ro, 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 av-
vengono 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 quan-
tit di dati strutturati in un formato predefinito (record) mettendoli in relazione tra di
loro e rendendoli disponibili, in modo uniforme e controllato, ad applicazioni eteroge-
nee (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) uti-
lizzano 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 nazio-
nalit 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 valuta-
re 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 memo-
ria 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 del-
le 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 ela-
borato 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 indiriz-
zo). 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 secon-
daria 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 me-
moria secondaria, sovrascrivendo lo spazio di memoria secondaria allocato in pre-
cedenza 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 al-
l'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
-OOc E
29 18 36 o 13!

-oo A B , - C D - E F

Kj
0 97 43 131 23 57 41 64 o 12

cuCD
Figura 5.4 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 cia-
scun 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 pri-
marie 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 , cari-
chiamo 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 ):


<chiave, indirizzoN> = 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 <indirizzoRadice, altezza;

Codice 5.9 Ricerca e inserimento in un B-albero di cui conosciamo l'altezza (pari a - 1 se l'albero
vuoto) e l'indirizzo logico della radice (pari a 0 se l'albero vuoto).

dalle chiavi del nodo (ricordiamo che il rango il numero di chiavi minori o uguali a
k). Il prossimo figlio da caricare indicato quindi nel riferimento associato alla chiave
avente tale rango come posizione (riga 6). Terminiamo quando troviamo un record (ov-
vero, l i v e l l o negativo), e quindi restituiamo tale record (se la chiave k appare, deve
apparire in tale record e come campo e . c h i a v e ) .
Non difficile modificare la ricerca in modo che esca dal ciclo quando l i v e l l o
zero, cos da elencare i record e con e. c h i a v e ^ k seguendo l'ordine indicato dalle
foglie del B-albero (usando il riferimento alla prossima foglia memorizzato nell'elemento
f[2 x B] della foglia corrente f). Poich effettuiamo il trasferimento di h = 0(log B n)
blocchi, il numero totale di trasferimenti 0 ( l o g B n). Inoltre il tempo di ricerca, oltre
a quello necessario per i trasferimenti, OflogB x log B n) = O(logn) perch la ricerca
binaria richiede O(logB) tempo per ciascuno degli h. livelli.
Per quanto riguarda l'operazione I n s e r i s c i , osserviamo che la modalit di bilan-
ciamento del B-albero differisce da quella dell'albero AVL, basata sulle rotazioni e sulla
crescita dell'albero verso le foglie: nel caso del B-albero, la crescita verso l'alto, con la
creazione di nuove radici. Precisamente, quando un nodo u contiene B chiavi, allora
creiamo un nuovo fratello u ' alla sua destra, che prende le ultime B/2 chiavi (quelle pi
grandi) da u. In tal modo, otteniamo due nodi con B/2 chiavi ciascuno e, inoltre, una
copia della chiave minima in u ' sale, insieme all'indirizzo logico di u ' , nel padre dei due
nodi u e u ' .
Tale operazione prende il nome di divisione (split) e richiede l'inserimento di una
nuova chiave (e l'indirizzo a essa associato) nel padre, il quale a sua volta pu arrivare
a contenere B chiavi, richiedendo esso stesso una divisione. Il meccanismo pu quindi
propagarsi nel caso pessimo di figlio in padre fino alla radice, la cui divisione crea una
nuova radice con due figli (motivando la condizione che 2 ^ g < B per la sola radice).
Inoltre, l'uso della chiave fittizia oo permette di mantenere l'invariante che l'inserimen-
to di una chiave non cambia mai la minima chiave del nodo corrente u, evitando cos di
dover riorganizzare l'associazione tra chiavi u[r] e indirizzi u[B + r].
Il meccanismo suddetto descritto nel Codice 5.9, il quale inserisce il record e (ri-
ga 2) ricorsivamente nella radice (che pu anche essere vuota, per cui va posta la sua
altezza al valore 1 la prima volta che invochiamo I n s e r i s c i ) . Se necessario, crea una
nuova radice utilizzando un array di appoggio b u f f e r ponendo la chiave speciale oo
come minima nel nuovo nodo (righe 4 e 5) e la chiave salita dal basso nella posizione
successiva (righe 6 e 7).
Gli indirizzi logici dei figli subiscono un trattamento analogo. Inoltre, il riferimen-
to al successore sempre nullo per la radice (riga 8), e l'elemento in ultima posizione
assume come valore la cardinalir dell'insieme delle due chiavi nella radice (riga 9). Infi-
ne, la nuova radice viene trasferita in memoria secondaria, annotando l'indirizzo logico
assegnatole (riga 10) e incrementando l'altezza (riga 11).
Resta da discutere l'inserimento ricorsivo, descritto nel Codice 5.10, e come esso
gestisce le divisioni che possono propagarsi verso l'alto. La ricorsione ha come caso base
(riga 2) l'allocazione in memoria secondaria del record e che contiene la chiave k (dove
e . c h i a v e = k), restituendo la coppia formata dalla chiave k stessa e dall'indirizzo logico
assegnato al record e (in modo da poterli inserire nell'opportuna foglia del B-albero).
Nello schema ricorsivo, dopo aver caricato in memoria principale il nodo del livel-
lo corrente (riga 3) e aver determinato il numero di chiavi in esso contenute (riga 4),
effettuiamo una ricerca binaria ricorsiva per determinare il rango (riga 5), come nell'o-
perazione R i c e r c a . A questo punto, inseriamo ricorsivamente la chiave k nel figlio del
nodo nella posizione individuata dal rango (riga 6). La chiamata ricorsiva restituisce una
coppia formata da una chiave e dall'indirizzo logico corrispondente (quello di u ' se il
figlio u ha subito una divisione oppure, se siamo in una foglia, quello del nuovo record
appena inserito nel caso base).
Se l'indirizzo logico 0, vuol dire che non dobbiamo propagare alcunch nel nodo
corrente e nei suoi antenati (riga 28). Altrimenti (righe 727), dobbiamo far posto alla
InsRicC indirizzo, livello, e ):
IF (livello < 0) RETURN <e.chiave, CreaSuDisco(e)>;
nodo = LeggiDaDiscoC indirizzo );
card = nodo[2 x B + 1];
rango = RicercaBinariaRicorsivaC nodo[0, card-1], e.chiave );
<chiave, indirizzoN> = InsRicC nodo[B+rango-1], livello-1, e);
IF (indirizzoN != 0) {
FOR (i = card; i > 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 <buffer[0], nodo[2 x B]>;
>
>
RETURN <0, 0>

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 asso-
ciato 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 rico-
piamo 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 prin-
cipale 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 tran-
sazioni 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 inter-
rogazione (come vedremo, i motori di ricerca sul Web rappresentano un noto esempio
di questa strategia). Infatti, il tempo di calcolo aggiuntivo che richiesto nella costru-
zione 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) costi-
tuiscono uno dei cardini nell'organizzazione di documenti in tale scenario. Le conside-
riamo 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 no-
zione 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 elemen-
to 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 opera-
zioni 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 <T,i> );
> ELSE {
elemento.chiave = T[i,j-1];
elemento.sat = NuovaListaC );
elemento.sat.InserisciFondoC <T,i> );
dizionarioListelnvertite.Inserisci( elemento )
>
i = j;
>
AlfaNumericoC c ):
RETURN ('a' <= c <= 'z' Il 'A' <= c <= 'Z' Il '0' <= c <= '9');

Codice 5.11 Costruzione di liste invertite di un testo T e D (elemento indica un nuovo elemento).

quello di identificare una sequenza alfanumerica massima rappresentata dal segmento di


testo T[i, j 1] per i < j (righe 48): tale termine viene cercato nel dizionario (riga 9) e,
se appare in esso, la coppia (T, i) che ne denota l'occorrenza in T viene posta in fondo alla
corrispondente lista invertita (righa 11); se invece T[i, j 1] non appare nel dizionario,
tale lista viene creata e memorizzata nel dizionario (righe 1316).

ALVIE: costruzione delle liste invertite

CE>
Osserva, sperimenta e verifica <>
InvertedList &> -

Supponendo che n sia il numero totale di caratteri esaminati, il costo della costru-
zione descritta nel Codice 5.11 pari a O(n) al caso medio usando le tabelle hash e
O ( n l o g n ) usando gli alberi di ricerca bilanciati. Nei casi reali, la costruzione concet-
tualmente simile a quella descritta nel Codice 5.11 ma notevolmente differente nelle pre-
stazioni. Per esempio, dovremmo aspettarci di applicare tale codice a una vasta collezione
di documenti che, per la sua dimensione, viene memorizzata nella memoria secondaria
in cui l'accesso a blocchi (come specificato nel Paragrafo 5.5). Per le liste invertite,
potremmo memorizzare anche le varie liste Lp nella memoria secondaria mentre il di-
zionario per il solo vocabolario V rimarrebbe in memoria principale; oppure, potremmo
usare un B-albero per mantenere anche V in memoria secondaria.
Ne risulta che, per l'analisi del costo finale, possiamo utilizzare il modello di me-
moria a due livelli valutando le varie decisioni progettuali: di solito, il vocabolario V
mantenuto in memoria principale, mentre le liste e i documenti risiedono in memoria
secondaria; tuttavia, diversi motori di ricerca memorizzano anche le liste invertite (ma
non i documenti) nella memoria principale utilizzando decine di migliaia di macchine
in rete, ciascuna dotata di ampia memoria prinicipale a basso costo.
Per quanto riguarda le interrogazioni effettuate in diversi motori di ricerca, esse pre-
vedono l'uso di termini collegati tra loro mediante gli operatori booleani: l'operatore
di congiunzione (AND) tra due termini prevede che entrambi i termini occorrano nel
documento; quello di disgiunzione (OR) prevede che almeno uno dei termini occorra;
infine, l'operatore di negazione (NOT) indica che il termine non debba occorrere.
E possibile usufruire anche dell'operatore di vicinanza ( N E A R ) come estensione del-
l'operatore di congiunzione, in cui viene espresso che i termini specificati occorrano a
poche posizioni l'uno dall'altro. Tali interrogazioni possono essere composte come una
qualunque espressione booleana, anche se le statistiche mostrano che la maggior parte
delle interrogazioni nei motori di ricerca consiste nella ricerca di due termini connessi
dall'operatore di congiunzione.
Le liste invertite aiutano a formulare tali interrogazioni in termini di operazioni ele-
mentari su liste, supponendo che ciascuna lista Lp sia ordinata. Per esempio, l'interro-
gazione (P AND Q) altro non che l'intersezione tra Lp e Lq, mentre (P OR Q) ne
l'unione senza ripetizioni: entrambe le operazioni possono essere svolte efficientemente
con una variante dell'algoritmo di fusione adoperato per il mergesort (Paragrafo 2.5.3).
L'operazione di negazione il complemento della lista e, infine, l'interrogazione (P NEAR
Q) anch'essa una variante della fusione delle liste Lp e Lq, come discutiamo ora in det-
taglio a scopo illustrativo (le interrogazioni AND e OR sono trattate in modo analogo): a
tale scopo, ipotizziamo che le coppie in Lp e Lq siano in ordine lessicografico crescente.
Il Codice 5.12 descrive come procedere per trovare le occorrenze di due termini P
e Q che distano tra di loro per al pi maxPos posizioni, facendo uso del Codice 5.13
per verificare tale condizione evitando di esaminare tutte le 0(|Lp| x |Lq|): le occorrenze
restituite in uscita sono delle triple composte da T, i e j, dove 0 ^ j i ^ maxPos, a
indicare che T [ i , i + |P| - 1] = P e T[j,j + |Q| - 1] = Q oppure T[i,i + |Q| - 1] = Q e
T[j, j + |P| 1] = P. Notiamo che tali triple sono fornite in uscita in ordine lessicografico
InterrogazioneNearC P, Q, maxPos ): (pre: maxPos ^ 0)
LP = Ricercai dizionarioListelnvertite, P );
LQ = Ricercai dizionarioListelnvertite, Q );
IF (LP != nuli && LQ != nuli) {
listaP = LP.sat.inizio;
listaQ = LQ.sat.inizio ;
WHILE (listaP != nuli && listaQ != nuli) {
<testoP, posP> = listaP.dato;
ctestoQ, posQ> = listaQ.dato;
IF (testoP < testoQ) {
listaP = listaP.succ;
} ELSE IF (testoP > testoQ) {
listaQ = listaQ.succ;
} ELSE IF (posP <= posQ) {
VerificaNeari listaP, listaQ, maxPos );
listaP = listaP.succ;
> ELSE {
VerificaNeari listaQ, listaP, maxPos );
listaQ = listaQ.succ;
>
}
>
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 422): 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 funzio-
ne avanza di una posizione sulla lista che contiene il testo con identificatore minore
(righe 1013). Altrimenti, i testi sono i medesimi per cui essa deve produrre tutte le
VerificaNear( listaX, listaY, M ): {pre: posX ^ posY)
<testoX, posX> = listaX.dato;
ctestoY, posY> = listaY.dato;
WHILE (listaY != null && testoX == testoY && posY-posX <= M) {
PRINT testoX, posX, posY;
listaY = listaY.succ;
<testoY, posY> = listaY.dato;

Codice 5 . 1 3 Algoritmo di verifica di vicinanza per l'interrogazione (P NEAR Q).

occorrenze vicine usando V e r i f i c a N e a r e distinguendo il caso in cui l'occorrenza di P


sia precedente a quella di Q o viceversa (righe 1419).

ALVIE: i n t e r r o g a z i o n e di v i c i n a n z a per le liste invertite

Osserva, sperimenta e verifica


NearQuery

Per la complessit notiamo che, se r indica il numero di triple che soddisfano l'inter-
rogazione di vicinanza e che, quindi, sono fornite in uscita con il Codice 5.12, il tempo
totale impiegato da esso pari a 0(|Lp| + |Lq| + r) e quindi dipende dalla quantit r di
risultati forniti in uscita (output sensitive). Tale codice pu essere modificato in modo da
sostituire la verifica di distanza sulle posizioni con quella sulle linee del testo.
Gli altri tipi di interrogazione sono trattati in modo analogo a quella per vicinanza.
Per esempio, l'interrogazione con la congiunzione (AND) calcolata come intersezione di
Lp e Lq richiede 0(|Lp| + |Lq|) tempo: da notare per, che se memorizziamo tali liste
usando degli alberi di ricerca, possiamo effettuare l'intersezione attraverso una ricerca di
ogni elemento della lista pi corta tra Lp e Lq nell'albero che memorizza la pi lunga.
Quindi, se m = min{|Lp|, |Lq|} e n = max{|Lp|, |Lq|}, il costo pari a O ( m l o g n ) e che,
quando m molto minore di n , risulta inferiore al costo 0 ( m + n) stabilito sopra. In
generale, il costo ottimo per l'intersezione pari 0 ( m . l o g ( n / m ) ) che sempre migliore
sia di O ( m l o g n ) che di 0 ( m + n ) . Possiamo ottenere tale costo utilizzando gli alberi che
permettono la finger search, in quanto ognuna delle m ricerche richiede O(logd) tempo
invece che O(logn) tempo, dove d < n il numero di chiavi che intercorrono, in ordine
di visita simmetrico, tra il nodo a cui perveniamo con la chiave attualmente cercata e
il nodo a cui siamo pervenuti con la precedente chiave cercata: al caso pessimo, abbia-
mo che le m ricerche conducono a m nodi equidistanti tra loro, per cui d = 0 ( n / m )
massimizza tale costo cumulativo fornendo O ( m l o g ( n / m ) ) tempo.
Tale costo motiva la strategia di risoluzione delle interrogazioni che vedono la con-
giunzione di t > 2 termini (invece che di soli due): prima ordiniamo le t liste invertite
dei termini in ordine crescente di frequenza (pari alla loro lunghezza); poi, calcoliamo
l'intersezione tra le prime due liste e, per 3 ^ i ^ t, effettuiamo l'intersezione tra l'i-
esima lista e il risultato calcolato fino a quel momento con le prime i 1 liste in ordine
non decrescente di frequenza. Nel considerare anche le espressioni in disgiunzione (OR),
procediamo come prima per ordine di frequenza delle liste, utilizzando una stima basata
sulla somma delle loro lunghezze.
Notiamo, infine, che alternativamente le liste invertite possono essere mantenute
ordinate in base a un ordine o rango di importanza delle occorrenze. Se tale ordine
preservato in modo coerente in tutte le liste, possiamo procedere come sopra con il
vantaggio di esaminare solo i primi elementi di ciascuna lista invertita, in quanto ter-
miniamo la scansione delle liste non appena raggiungiamo un numero sufficiente di
occorrenze che sono necessariamente di rango maggiore rispetto a quelle che scoprirem-
mo successivamente, proseguendo con la scansione: in tal modo, possiamo mediamente
evitare di esaminare tutti gli elementi delle liste invertite.

5.6.1 Trie o alberi digitali di ricerca


Per realizzare il vocabolario V nelle liste invertite abbiamo utilizzato finora le tabelle hash
oppure gli alberi di ricerca. Nel seguito, modelliamo i termini da memorizzare in V
come stringhe di lunghezza variabile, ossia come sequenze di simboli o caratteri presi da
un alfabeto L di a simboli, dove o un valore prefissato che non dipende dalla lunghezza
e dal numero delle stringhe prese in considerazione (per esempio, a = 256 per l'alfabeto
ASCII mentre o = 65536 per l'alfabeto UNICODE/UTF8). Ne deriva che ciascuna delle
operazioni di un dizionario per una stringa di m caratteri, richiede O(m) tempo medio
con le tabelle hash oppure O ( m l o g n ) tempo con gli alberi di ricerca, dove n = |V|:
in quest'ultimo caso, abbiamo O(logn) confronti da eseguire e ciascuno di essi richiede
O(m) tempo.
Mostriamo come ottenere un dizionario che garantisce O(m) tempo per operazione
utilizzando una struttura di dati denominata trie o albero digitale di ricerca, largamen-
te impiegata per memorizzare un insieme S = ci, a i> a n - i di n stringhe. Il ter-
mine trie si pronuncia come la parola inglese try e deriva dalla parola inglese retrieval
utilizzata per descrivere il recupero delle informazioni. I trie hanno innumerevoli ap-
plicazioni in quanto permettono di estendere la ricerca in un dizionario ai prefissi della
stringa cercata: in altre parole, oltre a verificare se una data stringa P appare in S, essi
permettono di trovare il pi lungo prefisso di P che appare come prefisso di una delle
stringhe in S (tale operazione non immediata con le tabelle hash e con gli alberi di
c a t a n i a c a t a n i
- O O O O O 0 O O O O V H O ]
Vz a r o
P i s a
'-O-CHCHI] VP ^ - o V A a

,c e I l i
0 0 0 0 - 0
n
a
O O 0

Figura 5.5 Trie per i nomi di al cune province (a sinistra) e sua versione potata (a destra).

ricerca). A tal fine, definiamo il prefisso i della stringa P di lunghezza m, come la sua
parte iniziale P[0, i], dove 0 ^ i ^ m 1. Per esempio, la ricerca del prefisso ve nell'in-
sieme S composto dai nomi di alcune province italiane, ovvero Catania, C a t a n z a r o ,
pisa, p i s t o i a , v e r b a n i a , Vercelli e v e r o n a , fornisce come risultato le stringhe
verbania, V e r c e l l i e verona.
Il trie per un insieme S = ao, Q i , . . . , a n _ i di n stringhe definite su un alfabeto I
un albero cardinale cr-ario (Paragrafo 4.4), i cui i nodi hanno cr figli (vuoti o meno), e la
cui seguente definizione ricorsiva in termini di S:

1. se l'insieme S delle stringhe vuoto, il trie corrispondente a S vuoto e viene


rappresentato con n u l i ;

2. se S non vuoto, sia S c l'insieme delle stringhe in S aventi c come carattere


iniziale, dove c e I (ai soli fini della definizione ricorsiva del trie, il carattere
iniziale c comune alle stringhe in S c viene ignorato e temporaneamente rimosso):
il trie corrispondente a S quindi composto da un nodo u chiamato radice tale
che u.f i g l i o [ c ] memorizza il trie ricorsivamente definito per S c (alcuni dei figli
possono essere vuoti).

Per poter realizzare un dizionario, ciascun nodo u. di un trie dotato di un campo


u . d a t o in cui memorizzare un elemento e: ricordiamo che e ha un campo di ricerca
e . c h i a v e , che una stringa in questo scenario, e un campo e . s a t per i dati satellite,
come specificato in precedenza. Mostriamo, nella parte sinistra della Figura 5.5, il trie
corrispondente all'insieme S di stringhe composto dai sette nomi di provincia menzionati
precedentemente.
Ricercai radiceTrie, P ): (pre: P una stringa di lunghezza m )
u = radiceTrie;
FOR (i = 0; i < m; i = i+1) {
IF (u.figlio[ P[i] ] != nuli) {
u = u.figlio[ P[i] ];
> ELSE {
RETURN nuli;
>
>
RETURN u.dato;

Codice 5.14 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 esem-
pio, 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 nel-
l'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 discen-
denti 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 identi-
ficare 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 pre-
fisso, 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 1418)
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 ): (pre: e. chiave ha lunghezza m)
IF (radiceTrie == nuli) {
radiceTrie = NuovoNodo( );
radiceTrie.dato = nuli;
FOR (c < 0; c < sigma; c = c + 1)
radiceTrie.figlio[c] = nuli;
>
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

O-O'O-O-O'O'
Osserva, s p e r i m e n t a e verifica O O 'o
Trie -o o

Inoltre, poich i trie preservano l'ordine lessicografico delle stringhe in esso conte-
nute, 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 S;

Codice 5.17 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 som-


ma 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 ri-
chiederebbe 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'ordi-
nameno) 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 risulta-
re 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 spe-
cificati 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 ripeti-
zioni 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'istra-
damento 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 pro-
grammazione 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 ulterior-
mente 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, classi-
fichiamo 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 op-
pure 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 per-
correndo 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'opera-
zione 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 rappresenta-


zione 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 rap-
presentiamo 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 ap-
pare 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 $ appa-
re 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 Fi-
gura 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 ): {pre: P contiene m caratteri)
u = radiceTrieCompatto;
fine = false;
i = 0;
WHILE (fine kk i < M) {
IF (u.figlioli P[i] ] != nuli) {
u = u.figliot P[i] ];
<e, j> = 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 (ri-
ghe 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 = <e, 0>;
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] ];
<p, j> = 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'ele-
mento 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 = <e, i>;
v.dato = nuli;
FOR (C < 0; C < sigma; C = C + 1)
v.figlio[c] = nuli;
<p, j> = u.prefisso;
v.figlio[ p.chiave[i] ] = u;
u = v;
>
u.figlio[ e.chiave[i] ] = NuovoNodo( );
u = u.figliof e.chiavefi] ];
u.prefisso = <e, m>;
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' con-
tenuto 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, ri-
spetto 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 ba-
sa 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 mat-
ching) 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 ab-
biamo 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 fon-
damentale 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 fondamenta-
le 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 docu-
menti 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 sottostrin-
ga 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 di-
zionario, come l'algoritmo di sostituzione testuale Lempel-Ziv su cui si basa il com-
pressore 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 rilevan-
te 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 fo-
glie 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) tra-
sferimenti 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 definizio-
ni 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 suc-
cessore, 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 es-
sere 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 rappre-
sentano 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 rappre-
senta 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 no-
ta compagnia aerea relativamente all'insieme V degli aeroporti dislocati presso alcune
capitali europee, identificate mediante il codice internazionale del corrispondente aero-
porto: 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 percor-
ribilit 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 at-
traversando 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
190 CIA
120 120
BVA BVA
90 " 90 "
120 175
DUB- 140 DUB- 140 160
'90 95
95
120 * 175
130 130
CRL- -NY0 'CRL- >NYO
160 I35
135
MAD STN MAD STN
110 115 HO 115

SXF TRF SXF. TRF

SXF CRL DUB STN MAD TRF BVA CIA NY0


SXF - - - 110 - - - - -

CRL - - 95 - - - - 120 130


DUB - 95 - - - - 90 190 -

STN 110 - - - - 115 - 160 135


MAD -

TRF - - - 115 - - - - -

BVA - - 90 - - - - 120 140


CIA - 120 190 160 - - 120 - 175
NY0 - 130 - 135 - - 140 175 -

Figura 6.2 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 mi-
nimi. 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 CIA
;
120
BVA BVA
90 ' 90 . '
175 '' \ 175
DUB DUB \ 140

NfO NYO

MAD MAD

TRF TRF

Figura 6.3 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 commutazio-
ne 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 presen-
tato 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 sotto-
grafo 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 no-
di 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 distin-
guibile da un arco (v,u), per cui (u,v) = (v,u): entrambi rappresentanto simmetrica-
mente 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 computazio-
nali 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 "cono-
scenze", 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 esattamen-
te 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 Vo-
Tornando 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 )
n

dei nodi che sia un cammino, ovvero (7ii,7ti + i) E per ogni 0 ^ i ^ n 2. Nel
Figura 6.6 La citt di Knigsberg (immagine proveniente da w w v . w i k i p e d i a . o r g ) con eviden-
ziati 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, vo-
gliamo 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 hamiltonia-
no 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 Knigsberg 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 fcile 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 propo-
sito 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 polino-
miali. 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 efficien-
te 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
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 0 0 0 0 0 0 0 0 0
TRF 0 0 0 1 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

Figura 6.7 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 = j+1) {


IF (A[i] [ j ] ! = 0) {
PRINT arco ( i , j ) ;
PRINT peso P [ i ] [ j ] 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 conte-


nente 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 il-
lustrato 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 | [ NYO
MAD
TRF STO
BVA DUB | 1 CIA | 1 NYO |
CIA CRL DUB | 1 STN | 1 BVA | 1 NYO
NYO CRL STN | 1 BVA CIA

Figura 6.8 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 ma-
trice 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 rappre-
sentazione 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 ave-
re 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 quel-
la 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 memo-
rizzato 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 memorizza-
to 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 cre-
scente 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 chia-


ro 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 sem-
plice 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
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

Figura 6.10 Matrice di adiacenza modificata per il calcolo della chiusura transitiva.

6.1.3 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 elemen-
ti della diagonale principale hanno tutti valori pari a 1 (come mostrato nella Figu-
ra 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 de-
riva 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
3

connessione tra i nodi per cammini di lunghezza qualunque. Indicheremo in generale


0 1 2 3 4 5 6 7 8 9 10 0 1 2 3 4 5 6 7 8 9 10
0 1 1 1 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0
1 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 2 1 1 1 1 1 1 1 1 0 0 0
3 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 0 1 0 0 0 4 1 1 1 1 1 1 1 1 0 0 0
5 1 1 0 1 1 1 1 1 0 0 0 5 1 1 1 1 1 1 1 1 0 0 0
6 1 1 0 1 0 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 7 1 1 1 1 1 1 1 1 0 0 0
8 0 0 0 0 0 0 0 0 1 1 1 8 0 0 0 0 0 0 0 0 1 1 1
9 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 10 0 0 0 0 0 0 0 0 1 1 1

Figura 6.11 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 rappre-
senta 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 ) ite-
razioni per ottenere la matrice A*. Da ci consegue che il costo computazionale del codi-
ce 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 ver-
tici 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, illu-
striamo 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 Figu-
ra 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'al-
tra 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'on-
da. 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 se-
gnale 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 chiu-
so [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 lunghez-
za d'onda e interessino uno stesso collegamento. Questo problema, spesso combinato
con quello dell'instradamento delle richieste, stato molto studiato e risulta essere mol-
to 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 sod-
disfatto se vale f i ^ f j per ogni coppia di richieste distinte [Qi,bi] e [aj,bj] che condi-
vidono 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) ottenu-
to da R come segue e come illustrato nella Figura 6.13: l'insieme VR = { 0 , 1 , . . . ,n1} dei
vertici rappresenta gli intervalli in R, dove i e VR corrisponde all'intervallo [at, bi]. L'in-
sieme 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 del-


l'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 al-
tre 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 pro-
blema, 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 croma-
tico 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 que-


st'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, que-


st'ultimo rilascia il colore assegnatogli affinch qualcun altro possa usarlo.

Per poter essere riutilizzati in seguito, i colori temporaneamente rilasciati vengono man-
tenuti 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'algorit-
mo 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 37).
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 compo-
nente 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 pos-
siamo 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] = <a[i],0,i>;
estremo[2 x i + 1] = <b[i],l,i>;
>
OrdinaCrescente( estremo );
FR (j = 0; j < 2 x n; j = j + 1) {
<x,estremoDestro,i> = 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 venga-
no 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 1012). 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 1618).
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 contie-
ne 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: notia-
mo 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 con-
testi 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 fraziona-
ti 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 ar-
chi 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'ela-
boratore): 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 interse-
cano, 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, scan-
diamo gli intervalli da sinistra verso destra: ogni qualvolta incontriamo la fine di un
intervallo che non ne interseca un altro precedentemente assegnato all'elaboratore, deci-
diamo di assegnare tale intervallo all'elaboratore stesso. In altre parole, questo approc-
cio 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 36). 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 ter-
minare, 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 esa-
minato 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 indi-
pendente. 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] = <b[i],i>;
>
OrdinaCrescente( estremoDestro );
ultimo = -1;
FOR (j = 0; j < n; j = j + 1) {
<x,i> = 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 al-
l'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 del-
l'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 de-
cidono 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 inter-
vallo, 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 neces-
sario. Nel caso del problema del massimo insieme indipendente, invece, ogni qualvolta
esaminiamo un nuovo intervallo decidiamo di includerlo nella soluzione se ci compa-
tibile 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 princi-
pali da affrontare consiste nel decidere in che ordine dobbiamo esaminare i componenti
di un'istanza del problema. Nel caso del problema del massimo insieme indipenden-
te 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.
base 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).

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 comples-
se, 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 mol-
to 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 produ-
zione 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 at-
tori, 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 do-
cumenti di una qualche natura. Il grafo del Web un esempio di rete di questo tipo, co-
me 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 caratterizza-
zione di parametri ritenuti significativi: ad esempio, cerchiamo le propriet che caratte-
rizzano 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 ma-
tematico, della struttura generale di un qualunque grafo di grandi dimensioni che rap-
presenti una rete del tipo di quelle citate sopra. Tale caratterizzazione potr essere uti-
lizzata 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 ta-
le 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 carat-
teristiche 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 coeffi-
ciente 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,

= ^ (6-1)
d v ( d v - 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 stati-
stico, 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.
GeneraErdsRnyi (n, p) : {pre: 0 ^ p ^ 1)
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 Erds-Rnyi.

6.3.1 Grafi casuali alla Erds-Rnyi


Il modello classico di grafi non orientati costruiti in base a un processo casuale il co-
siddetto modello di Erds-Rnyi, 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 pre-
senza 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 Codi-
ce 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 Erds-Rnyi

Osserva, sperimenta e verifica


ErdosRenyiGraph

La distribuzione dei gradi dei nodi in un grafo casuale alla Erds-Rnyi descritta
dalla probabilit pd che un nodo abbia grado d, probabilit caratterizzata dalla nota
distribuzione di Bernoulli

n 1 d n d 1
pd = ( ^ )p d-p) - - (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

z
pa = -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 se-
guito. 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 diffe-
risce significativamente dalla distanza media tra due nodi scelti in modo casuale e che
quest'ultima logaritmica nel numero di nodi. Per giustificare informalmente quest'af-
fermazione 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 con-
testi, i grafi casuali di tipo Erds-Rnyi non sembrano per adatti a rappresentare reti
complesse come quelle discusse in precedenza. Tale difformit deriva da due fattori: co-
me 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-Rnyi riguarda
l'andamento della dimensione delle varie componenti connesse al crescere della proba-
bilit 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 ca-
sualmente 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 Erds-Rnyi 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 pa-
ri, 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 Erds-Rnyi 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 Erds-Rnyi, 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 sottoin-
sieme 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 ge-
nerate 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 rappresenta-
zione mdiante 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 co-
dice 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 <= d/2; j = j + 1) {
v = (u+j) '/. n;
i = (n-1) x randomO ;
IF (i < v) {
w = i;
> ELSE {
w = i + 1;
>
IF (randomO < p') {
AggiungiListaAdiacenzaC u, w );
> 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 significativa-
mente mentre il coefficiente di aggregazione rimasto immutato: in questo intervallo la
rete presenta quindi l'effetto desiderato di piccolo mondo. In particolare, potremmo sce-
gliere 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 Erds-Rnyi,
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 ot-
tenere 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 espo-
nenziale, 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 sca-
la 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 pre-
sentano 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 Erds-Rnyi, dove decresce esponenzial-
mente 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 osser-
vare 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 27).
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 distribuzio-
ne 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 proba-
bilit 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 qua-
lunque 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:

2 t
p(d, t) = ^ - ^ p ( d - l , t - l ) + ~^~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 Inter-
net 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 do-
cumenti che soddisfano una certa interrogazione (query), generalmente definita usando i
termini presenti nei documenti (per esempio, trovare tutti i documenti contenenti i ter-
mini "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 catalo-
gazione dei documenti accessibili sul Web e che consentono di interrogare velocemente
il catalogo cos costruito utilizzando, ad esempio, le liste invertite descritte nel Paragra-
fo 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 ven-
gano 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 tradi-
zionalmente calcolata sulla base del loro solo contenuto. Con l'avvento delle tecnologie
ipertestuali, in cui i documenti possono essere arricchiti con riferimenti ad altri docu-
menti (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 in-
formazioni 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 docu-
menti, 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 riferi-
mento 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 arti-
ficioso 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 in-
gresso 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 rile-
vanza 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 fun-
zione 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 perio-
dicamente) il rango di tutte le pagine raccolte: le pagine restituite in seguito a un'in-
terrogazione 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 iden-
tifica 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 deci-
dere 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 Pa-
geRank, 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 forni-
ti 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 pagi-
na 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 pagi-
na 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:

rango(t
r a n g o ( j ) = (1 - a) + a Y_ (6.6)
uscita(i)
ieE(j)

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 va-
lore 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 38). 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 ese-
guono 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 1415) corrisponde al calcolo del
valore X!igE(j) usTita(V) P r e s e n t e nell'equazione (6.6). Quando il ciclo d o - w h i l e ter-
mina (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 ^Jo' A[i] [j] > 0 per 0 ^ i < n)
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;
>
DO {
FOR (j = 0; j < N; j = j+1)
Y[j] = X[j] ;
FOR (j = 0; j < n; j = j+1) {
X[j] = 0;
FOR (i = 0; i < n; i = i+1)
X[j] = X[j] + A[i] [j] x Y[i] / uscitati] ;
X[j] = ( 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 nu-
mero 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 mo-
strato 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 cam-
bia 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, ge-
stendo 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 0,8

Figura 6.20 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 si-
gnificative 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 con-
tengono esplicitamente il termine "automobile". Inoltre tali pagine non si riferiscono
vicendevolmente in modo diretto, mentre lo fanno indirettamente attraverso qualche lo-
ro 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 ri-


ferisce e, quindi, il peso del primo direttamente proporzionale al peso delle
ultime;

un'autorit tanto pi significativa quanto lo sono i concentratori che la riferi-


scono 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'insie-
me 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)


kU(j)

Analogamente al PageRank, possiamo effettuare il calcolo di tali funzioni median-


te un procedimento iterativo, assegnando un valore iniziale pari a ^ a ciascuna pagi-
na 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 = sommaW = 0;
FOR (j = 0; j < N; j = j+1)
{ Y[j] = X[j] ; Z[j] = W[j] ; >
FOR (j = 0; j < n; j = j+1) {
X[j] = W[j] = 0;
FOR (i = 0; i < n; i = i+1)
X[j] = X[j] + A[i] [j] x Z[i] ;
FOR (k = 0; K < N; k = k + 1 )
W[j] = W[j] + A[j] [k] x Y[k] ;
sommaX = sommaX + X[j];
sommaW = 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 <X, W>;

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: notia-
mo 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 se-
condo 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 ) tem-


po: 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 costru-
zione 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
prestazini).
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 precisamen-
te 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 itera-
zione 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 ( A[]p]nX<k-"[i]) (6.9)


V
v uscitat xJ
i=0

per 0 ^ j < n e k ) 1. Osserviamo che, per ogni k ^ 0, ^"JQ 1 X (k) [j] = n . Ci


vero per k = 0. Supponendo che lo sia per ogni h. < k e applicando l'equazione (6.9),
abbiamo che

"tV'ra - + v
j =0 j=0 i=0 '

- <1-)""Z:^-raEsS
i=0 j=0
n 1
= ( 1 - a ) n + <x ^ X , k - 1 ) [ i ] = ( 1 a ) n + a n = n
i=0

dove la terza uguaglianza dovuta al fatto che u s c i t a t i ] = ^.^Jo A[i][)] per ogni 0 ^
i < n, mentre la quarta uguaglianza segue dall'ipotesi induttiva che ^ { ^ Q ' X ( k _ 1 ) [i] = TI.
Mostriamo ora che l'insieme delle equazioni (6.9), una per ogni valore di j, costitui-
sce un sistema di equazioni lineari, che pu essere espresso mediante moltiplicazione di
matrici. A tal fine, definiamo la matrice M di dimensione n x n , tale che

1 _ a
imr-ir-i =
M[)][t] +, ot-
ri uscitati]

per 0 ^ j , i < n. Possiamo verificare che

X,k' = M x X ' k ~ "

in quanto X ( k 'tj] = n P e r g n ' k ^ 0. Quindi, X ( k ) = M k X ( 0 ) dove ricordiamo


k
che M il risultato della moltiplicazione di M per se stessa k volte: pertanto, il Codi-
ce 6.6 converge se esiste un k tale che M k = M k _ 1 . Il teorema di Perron-Frobenius ci
permette di affermare che ci asintoticamente vero in quanto gli elementi di M sono
tutti positivi: per ogni e > 0, esiste k e ^ 1 tale che |M k [j][i] M k - 1 [j][t]| < e per
0 ^ i, j < n . Per questo motivo, la condizione di terminazione del Codice 6.6 deve
essere espressa in funzione di un grado di precisione e che deve essere fornito in ingresso
insieme alla matrice A. Inoltre, lo stesso teorema garantisce che la soluzione calcolata
non dipende dalla scelta di X' 0 '.
Il teorema di Perron-Frobenius potrebbe essere applicato anche al Codice 6.7 modi-
ficando in modo opportuno la matrice A in ingresso. Tuttavia, altri risultati di algebra
lineare consentono di affermare che il Codice 6.7 converge sempre (specificando la con-
dizione di terminazione in base a un grado di precisione), anche se la soluzione calcolata
pu dipendere dai valori assegnati inizialmente a X e W.

RIEPILOGO
In questo capitolo abbiamo esaminato le caratteristiche principali dei grafi, fornendo le
relative definizioni e mostrando come utilizzarli per modellare una quantit di situazioni
e problemi reali. Abbiamo mostrato come rappresentare i grafi e come risolvere, mediante
il paradigma dell'algoritmo goloso, il problema della colorazione e del massimo insieme
indipendente nel caso di grafi a intervalli. Abbiamo quindi discusso le reti complesse e come
generarle probabilisticamente e abbiamo, infine, studiato il problema della classificazione
dei documenti nei motori di ricerca utilizzando la struttura a grafo del Web.

ESERCIZI

1. Discutete gli algoritmi per inserire o cancellare un nodo o un arco in un grafo


in relazione alle due rappresentazioni dei grafi discusse (rappresentazione di grafi
dinamici).
2. 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'interpretazio-
ne che nella partita del torneo tra x e y uno dei due ha vinto. Mostrate che un
grafo a torneo ammette sempre un cammino Hamiltoniano.

3. Descrivete un algoritmo lineare per stabilire se un grafo bipartito, tentando di


usare il colore opposto del vertice corrente quando scoprite un nuovo vertice.

4. Mostrate che le seguenti due varianti del paradigma dell'algoritmo goloso non
calcolano un insieme indipendente di cardinalit massima in un grafo a intervalli:
(a) una volta ordinati gli intervalli in ordine non decrescente rispetto alla loro
lunghezza, l'algoritmo esamina gli intervalli uno dopo l'altro e li include nella
soluzione se ci possibile;
(b) l'algoritmo seleziona l'intervallo con il minor numero di intersezioni, include
tale intervallo nella soluzione, elimina tutti gli intervalli che lo intersecano e, se vi
sono ancora intervalli, ripete tale procedimento.

5. Mostrate come ridurre la complessit del Codice 6.3 per la costruzione di un grafo
alla Erds-Rny, da 0 ( n 2 ) tempo a O(np) tempo, quindi con complessit lineare.

6. Mostrate che X | k ) = M x X ( k _ 1 ) , dove X ( h ) per h. ^ 0 e M sono definite nel


Paragrafo 6.4.3.
Capitolo 7

Pile e code

SOMMARIO
In questo capitolo, definiamo e analizziamo due strutture di dati comunemente utilizzate
in contesti informatici (e non solo) per la gestione di sequenze lineari dinamiche, ovvero le
pile e le code. Per ciascuna di esse, descriviamo due diversi possibili modi di implementarle
e forniamo poi alcuni esempi significativi di applicazione nella gestione della notazione
polacca poslfissa, nella visita di grafi, nel loro ordinamento topologico e nel calcolo delle
componenti connesse.

DIFFICOLT
1 CFU

7.1 Pile
Una pila una collezione di elementi in cui le operazioni disponibili, come l'estrazione
di un elemento, sono ristrette unicamente a quello pi recentemente inserito. Questa
politica di accesso, detta LIFO (Last In First Out), comporta che l'ordine con cui gli
elementi sono estratti dalla pila opposto rispetto all'ordine dei relativi inserimenti e,
ad esempio, riflette quanto avviene per la pila di vassoi, in cui il vassoio che possiamo
prendere sempre quello in cima alla pila che anche l'ultimo a essere stato riposto.
L'insieme delle operazioni caratteristiche di una pila composto da tre operazioni,
due delle quali inseriscono ed estraggono rispettivamente l'elemento in cima alla pila,
mentre la terza restituisce tale elemento senza estrarlo.
In particolare, la prima operazione prende il nome di P u s h e inserisce un nuovo
elemento in cima alla pila; la seconda detta Pop ed estrae l'elemento in cima alla pila
restituendo l'informazione in esso contenuta; la terza detta Top e restituisce l'informa-
zione contenuta nell'elemento in cima alla pila senza estrarlo. In alcune applicazioni
utile avere anche l'operazione Empty che verifica se la pila vuota o meno.
Come vedremo, ogni operazione invocata su di una pila pu essere eseguita in tempo
costante, indipendentemente dal numero di elementi contenuti nella pila stessa: che ci
sia possibile pu essere verificato immediatamente considerando il caso della pila di vas-
soi, in quanto riporre o prendere un vassoio richiede lo stesso tempo, indipendentemente
da quanti siano i vassoi sovrapposti.
In effetti, se gli elementi nella pila sono mantenuti ordinati secondo l'istante di in-
serimento, tutte e tre le operazioni agiscono su un'estremit (la cima della pila) della
sequenza. Basta quindi avere la possibilit di accedere direttamente a tale estremit per
effettuare le operazioni in tempo indipendente dalla dimensione della pila: in questo
paragrafo proponiamo due specifiche implementazioni della struttura di dati pila, che
consentono effettivamente di fare ci.

7.1.1 Implementazione di una pila mediante un array


Una pila pu essere implementata utilizzando un array. In particolare, gli elementi della
pila sono memorizzati in un array di dimensione iniziale pari a una costante predefinita.
Successivamente, la dimensione dell'array viene raddoppiata o dimezzata per garan-
tire che sia proporzionale al numero di elementi effettivamente contenuti nella pila: l'a-
nalisi del metodo di ridimensionamento di un array (discussa nel Paragrafo 2.1.3) mostra
che occorre un tempo costante ammortizzato per operazione.
Gli elementi della pila sono memorizzati in sequenza nell'array a partire dalla loca-
zione iniziale, inserendoli man mano nella prima locazione disponibile: ci comporta
che la "cima" della pila corrisponde all'ultimo elemento di tale sequenza. Baster quindi
tenere traccia dell'indice della locazione che contiene l'ultimo elemento della sequen-
za per implementare le operazioni Push, Pop, Top e Empty in modo che richiedano
tempo costante ammortizzato, come mostrato nel Codice 7.1, in cui ipotizziamo che la
pila sia rappresentata per mezzo di un array p i l a A r r a y di dimensione variabile (gestito
mediante le funzioni 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 ) .
La cima della pila corrisponde all'elemento dell'array il cui indice memorizzato nel-
la variabile c i m a P i l a , inizialmente posta uguale a 1. Facendo uso di tale informazione
le operazioni di accesso alla pila sono molto semplici da realizzare. Infatti, l'elemento in
cima alla pila sar sempre p i l a A r r a y [ c i m a P i l a ] .
L'operazione P u s h incrementa c i m a P i l a , dopo avere verificato che l'array non sia
pieno (nel qual caso la sua dimensione andr raddoppiata). L'operazione Pop richiede di
verificare se la pila non vuota invocando la funzione Empty e, in tal caso, di decremen-
tare il valore di c i m a P i l a , verificando che l'array non sia poco popolato (nel qual caso
la sua dimensione andr dimezzata).
Osserviamo come, nel caso di un'operazione Pop, il contenuto dell'elemento dell'ar-
ray che si trova nella posizione specificata da c i m a P i l a , non debba essere necessaria-
Push( x ) :
VerificaRaddoppio( );
cimaPila = c i m a P i l a + 1;
p i l a A r r a y [ c i m a P i l a ] = x;

Pop( ) :
IF (!Empty( ) ) {
x = pilaArrayE cimaPila ] ;
cimaPila = c i m a P i l a - 1;
VerificaDimezzamento( ) ;
RETURN X;
>
Top( ) :
IF (!Empty( ) ) RETURN p i l a A r r a y t c i m a P i l a ] ;

Empty( ) :
RETURN ( c i m a P i l a == -1);

Codice 7.1 Implementazione di una pila mediante un array: le funzioni Verif icaRaddoppio e
VerificaDimezzamento seguono l'approccio del Paragrafo 2.1.3.

mente azzerato, in quanto nel momento in cui faremo di nuovo accesso a tale elemento,
il suo contenuto sar stato modificato dalla corrispondente operazione Push.

ALVIE: implementazione di una pila mediante un array

i n rr.TTn-rrnM
Osserva, sperimenta e verifica
StackArray

7.1.2 Implementazione di una pila mediante una lista


Una pila pu essere implementata anche utilizzando una lista i cui elementi sono man-
tenuti ordinati in base al loro tempo di inserimento decrescente. In tal modo, la "cima"
della pila corrisponde all'inizio della lista, e le operazioni agiscono tutte sull'elemento
iniziale della lista stessa. Nel Codice 7.2, il riferimento all'elemento in cima alla pila
memorizzato nella variabile c i m a P i l a e ciascun elemento contiene oltre all'informa-
zione un riferimento all'elemento successivo ( c i m a P i l a n u l i nel caso in cui la pila
Push( x ) :
u = NuovoNodo( ) ;
u . d a t o = x;
u.succ = cimaPila;
c i m a P i l a = u;

Pop( ) :
IF (! Empty( ) ) {
x = cimaPila.dato ;
cimaPila = cimaPila.succ;
RETURN X;
>
Top( ) :
IF (!Empty( ) ) RETURN c i m a P i l a . d a t o ;

Empty( ) :
RETURN ( c i m a P i l a == n u l i ) ;

Codice 7.2 Implementazione della pila mediante una lista.

sia vuota). Facendo uso di tali riferimenti, le operazioni di accesso alla pila sono quindi
altrettanto semplici da realizzare di quelle esaminate nel paragrafo precedente. La mo-
dalit di allocazione di un nodo nella lista (riga 2 in Push) dipende dal linguaggio di
programmazione adottato.

ALVIE: i m p l e m e n t a z i o n e di u n a pila m e d i a n t e u n a lista

Jhfe^ Osserva, sperimenta e verifica


^UK/ StackList

7.2 Opus libri: Postscript e notazione postfissa


Il Postscript un linguaggio di programmazione per la grafica che viene eseguito da un
interprete che utilizza la pila e la notazione postfissa o polacca inversa definita di seguito.
Se un'operazione in Postscript ha k argomenti, questi ultimi si trovano nelle k posizioni
in cima alla pila, ovvero l'esecuzione di k operazioni Pop fornisce gli argomenti all'ope-
razione in Postscript, il cui risultato viene posto sulla pila tramite un'operazione Push.
L'ampia diffusione del linguaggio Postscript lo rende uno degli standard tipografici prin-
cipalmente adottati, insieme alla sua evoluzione PDF (Portable Document Format), per
la stampa professionale (inclusa quella del presente libro). Il principio del suo funziona-
mento basato sulla pila intuitivo e possiamo illustrarlo usando le espressioni aritmetiche
come esempio.
uso comune in matematica scrivere l'operatore tra gli operandi, come in A + B,
piuttosto che dopo gli operandi, come in AB+ (nel seguito, supponiamo che gli operatori
siano tutti binari). La prima forma si chiama notazione infissa mentre la seconda si
chiama postfissa o polacca inversa dalla nazionalit del matematico Lukasiewicz che ne
studi le propriet.
La notazione postfissa ha alcuni vantaggi rispetto a quella infissa. Anzitutto, le espres-
sioni scritte in notazione postfissa non hanno bisogno di parentesi (l'ordine degli ope-
randi viene preservato rispetto all'infissa). In secondo luogo, non necessario specificare
una priorit, talvolta arbitraria, degli operatori (ad esempio, il fatto che A + B x C sia
equivalente a A + (B x C) dovuto al fatto che la moltiplicazione ha, in base a una
definizione arbitraria, priorit superiore alla somma).
Infine, tali espressioni si prestano a essere valutate semplicemente, da sinistra a de-
stra, mediante l'uso di una pila applicando le seguenti regole in base al simbolo letto,
supponendo di avere operazioni binarie:
operando: viene eseguita la P u s h di tale operando;
operatore: vengono eseguite due Pop, l'operatore viene applicato ai due operandi
prelevati dalla pila (nel giusto ordine) e viene eseguita la P u s h del risultato.

ALVIE: valutazione di un'espressione postfissa mediante una pila

Osserva, sperimenta e verifica


PostfixEvaluation ._

Oltre che per la valutazione di espressioni algebriche in notazione polacca inversa, il


tipo di dati pila pu essere usato anche per convertire un'espressione algebrica in forma
infssa in un'espressione algebrica equivalente in forma postfissa. Concettualmente, pos-
siamo costruire l'albero che rappresenta l'espressione infissa, visitandolo in ordine posti-
cipato per ottenere l'espressione postfissa, ma possiamo evitare questo doppio passaggio
usando direttamente una pila.
Supponiamo per semplicit che le parentesi siano comunque esplicitate, per cui un'e-
spressione data da una coppia di parentesi al cui interno ci sono due espressioni (ricor-
sivamente definite) separate da un operatore. A questo punto la trasformazione avviene
simbolo corrente dell'espressione infissa
cima della pila $ + oppure x oppure / ( )
$ 4 1 1 1
4- oppure 2 2 1 1 2
x oppure / 2 2 2 1 2
( 1 1 1 3

Figura 7.1 Tabella per la conversione di un'espressione infissa in una postfissa.

leggendo l'espressione infissa da sinistra a destra e applicando le seguenti regole in base


al simbolo letto:
parentesi aperta: viene ignorata;
operando: viene appeso direttamente in fondo all'espressione postfissa in costru-
zione senza passare per la pila;
operatore: viene eseguita la P u s h di tale operatore;
parentesi chiusa: viene eseguita la Pop per riprendere l'operatore che viene appeso
in fondo all'espressione postfissa in costruzione.

ALVIE: conversione di un'espressione infissa con parentesi esplicite in una postfissa

Osserva, sperimenta e verifica


F u l l l n f ixPostf ix

In realt, non abbiamo bisogno di imporre le parentesi nell'espressione infissa quan-


do non sono necessarie. Per verificare tale affermazione, ipotizziamo che l'espressione
infissa sia composta dei seguenti simboli: variabili e costanti (ovvero, lettere alfanumeri-
che), operatori binari (ovvero, + , , x, / ) e parentesi (ovvero, ( e ) ). Supponiamo inoltre
che l'espressione infissa sia sintatticamente corretta (ad esempio, non sia A + xB) e sia
terminata dal simbolo speciale $. L'esecuzione delle azioni inizia ponendo una copia del
simbolo $ nella pila vuota, e ha termine quando l'espressione infissa diviene vuota.
Durante la conversione, se il simbolo corrente nell'espressione infissa un operando
(una variabile o una costante), esso viene appeso direttamente in fondo all'espressione
postfissa in costruzione senza passare per la pila. Altrimenti, il simbolo corrente un
operatore, una parentesi oppure il simbolo $, e le regole per elaborare tale simbolo so-
no rappresentate succintamente nella tabella in Figura 7.1, dove il numero contenuto
all'incrocio di una riga e di una colonna rappresenta una delle seguenti azioni, da intra-
prendere quando il simbolo di riga sulla cima della pila e il simbolo di colonna quello
attualmente letto nell'espressione infissa:
1. viene eseguita la P u s h del simbolo corrente dell'espressione infissa;
2. viene eseguita una Pop e l'operatore cos ottenuto viene appeso in fondo all'espres-
sione postfissa in costruzione;
3. il simbolo corrente viene ignorato nell'espressione infissa e viene eseguita una Pop
(ignorando il simbolo restituito);
4. la conversione ha avuto termine, il simbolo corrente viene cancellato dall'espres-
sione infissa e viene eseguita una Pop (ignorando il simbolo restituito).
Usando la tabella in Figura 7.1, possiamo trasformare anche espressioni che hanno
delle parentesi implicitamente definite dall'associativit a sinistra e dalla precedenza degli
operatori, come nel caso di 6 + (5 4) x ( 1 + 2 + 3). Il costo computazionale dell'al-
goritmo di conversione e di valutazione O(n) tempo per un'espressione di n simboli,
ipotizzando che il costo di valutazione di un singolo operatore sia costante e quindi non
dipenda dalla lunghezza dell'espressione.

ALVIE: conversione di un'espressione infissa in una postfissa

Osserva, sperimenta e verifica


InfixPostfix

7.3 Code
Analogamente alla pila, la coda una collezione di elementi in cui le operazioni di-
sponibili sono definite dalla seguente politica di accesso: mentre nella pila l'accesso
consentito solo all'ultimo elemento inserito, nella coda estraiamo il primo elemento, in
"testa" alla coda, essendo presente da pi tempo, mentre inseriamo un nuovo elemen-
to in fondo alla coda, perch pi recente. Ci corrisponde a quanto avviene in molte
situazioni quotidiane come, ad esempio, nel pagare un pedaggio autostradale, nel fare
acquisti in un negozio e, in generale, nel ricevere una serie di eventi o richieste da ela-
borare. Una politica del tipo suddetto viene detta FIFO (First In First Out) in quanto il
primo elemento a essere inserito nella coda anche il primo a essere estratto.
Le operazioni principali definite su una coda permettono di inserire un nuovo ele-
mento nella coda e di estrarre un elemento dalla coda stessa in tempo costante: E n q u e u e
inserisce un nuovo elemento in fondo alla coda, D e q u e u e estrae l'elemento dalla testa
della coda e restituisce l'informazione in esso contenuta e First restituisce l'informa-
zione contenuta nell'elemento in testa alla coda, senza estrarre tale elemento. Infine,
l'operazione Empty verifica se la coda vuota. Mentre nella pila c' un unico punto
di accesso su cui le operazioni vanno a incidere (quello corrispondente alla "cima" della
pila), nel caso della coda ne esistono due, ovvero le estremit della coda stessa, in quanto
F i r s t e Dequeue vanno a operare sulla "testa" della coda, mentre Enqueue incide sul
"fondo" della coda.

7.3.1 Implementazione di una coda mediante un array


L'implementazione di una coda mediante un array consiste nel memorizzare gli elementi
della coda in un array di dimensione variabile. Gli elementi della coda sono memorizzati
in sequenza nell'array a partire dalla locazione associata all'inizio della coda, inserendoli
man mano nella prima locazione disponibile: ci comporta che la fine della coda cor-
risponde all'ultimo elemento di tale sequenza. Baster quindi tenere traccia dell'indice
(indicato con t e s t a C o d a ) della locazione che contiene il primo elemento della sequen-
za e di quello (indicato con f ondoCoda) della locazione in cui poter inserire il prossimo
elemento, per implementare le operazioni F i r s t , Enqueue e Dequeue in tempo co-
stante ammortizzato. Tuttavia, per poter sfruttare al meglio lo spazio a disposizione e
non dover spostare gli elementi ogni volta che un oggetto viene estratto dalla coda, la
gestione dei due indici t e s t a C o d a e f ondoCoda avviene in modo "circolare" rispetto
alle locazioni disponibili nell'array. In altre parole, se uno di questi due indici supera
la fine dell'array, allora esso viene azzerato facendo, quindi, in modo tale che indichi la
prima locazione dell'array stesso.
Nel Codice 7.3, utilizziamo tre interi c a r d C o d a , t e s t a C o d a e f o n d o C o d a che
sono stati inizializzati rispettivamente con i valori 0, 0 e 1 : inizialmente la coda vuota
(quindi, contiene c a r d C o d a = 0 elementi e t e s t a C o d a vale 0) e il prossimo elemen-
to potr essere inserito nella prima locazione dell'array (quindi, f o n d o C o d a vale 1
perch viene prima incrementato). Il metodo Empty si limita a verificare se il valore
di c a r d C o d a uguale a 0. L'operazione di incremento di t e s t a C o d a e fondoCoda
circolare, per cui adoperiamo il modulo della divisione intera a tal fine (riga 4 in
Enqueue e riga 5 in Dequeue, nelle quali supponiamo che la lunghezza dell'array
sia memorizzata nella variabile l u n g h e z z a A r r a y ) . Inoltre, osserviamo che, quando
a r r a y C o d a deve essere raddoppiato o dimezzato, le funzioni 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 ricollocano ordinatamente gli elementi della coda nelle
prime celle dell'array e pongono t e s t a C o d a a 0 e f o n d o C o d a a c a r d C o d a 1.

7.3.2 Implementazione di una coda mediante una lista


L'implementazione pi naturale di una coda mediante una struttura con riferimenti con-
siste in una sequenza di nodi concatenati e ordinati in modo crescente secondo l'istante
di inserimento. In tal modo, il primo nodo della sequenza corrisponde alla "testa" della
Enqueue( x ):
VerificaRaddoppio( );
cardCoda = cardCoda + 1 ;
fondoCoda = (fondoCoda + 1) "/, lunghezzaArray ;
codaArrayt fondoCoda ] = x;

Dequeue( ):
IF (!Empty( )) {
cardCoda = cardCoda - 1;
x = codaArrayt testaCoda ];
testaCoda = (testaCoda + 1) '/, lunghezzaArray;
VerificaDimezzamento( );
RETURN X;
>
First( ) :
IF (!Empty( )) RETURN codaArrayt testaCoda ];

Empty( ):
RETURN (cardCoda == 0);

Codice 7.3 Implementazione della coda mediante un array: le funzioni Verif icaRaddoppio e
Verif icaDimezzamento seguono l'approccio del Paragrafo 2.1.3 e aggiornano anche
i valori di t e s t a C o d a e di fondoCoda.

coda ed il nodo da estrarre nel caso di una D e q u e u e , mentre l'ultimo nodo corrisponde
al "fondo" della coda ed il nodo a cui concatenare un nuovo nodo, inserito mediante
Enqueue. Lasciamo al lettore il compito di definire tale implementazione sulla falsariga
di quanto fatto per la pila nel Codice 7.2 e per le liste doppie nel Paragrafo 5.2.

7.4 Opus libri: Web crawler e visite di grafi


Abbiamo gi visto che uno degli esempi pi noti di grafo orientato G = (V, E) fornito
dal World Wide Web in cui l'insieme dei vertici in V costituito dalle pagine Web e
l'insieme degli archi orientati in E sono i collegamenti tra le pagine. Non abbiamo,
per, ancora detto come tali collegamenti sono rappresentati all'interno delle pagine e
come i crawler di un motore di ricerca (Paragrafo 6.4) possano recuperarli e utilizzarli
nel processo di raccolta delle pagine Web.
Ogni pagina Web identificata da un indirizzo URL ( U n i f o r m Resource Locator).
Per esempio, / / w w w . w 3 . o r g / A d d r e s s i n g / A d d r e s s i n g . h t m l l'URL della pagi-
na Web in cui descritta la specifica tecnica delle URL: la parte racchiusa tra / / e
il primo / successivo (www.w3.org) un riferimento simbolico a un indirizzo di un
calcolatore ospite (host) connesso a Internet. Tale indirizzo una sequenza di 32 bit
se viene utilizzato il protocollo IPv4 (oppure di 128 bit nel caso di IPv6). Infine, la
parte rimanente dell'URL, ovvero / A d d r e s s i n g / A d d r e s s i n g . h t m l , indica un per-
corso interno al file system dell'host, che identifica il file corrispondente alla pagina Web.
Un collegamento da una pagina a un'altra specificato, all'interno della prima, utiliz-
zando la seguente sintassi definita nel linguaggio H T M L (HyperText Markup Langua-
ge), che permette di associare, tra l'altro, un testo a ogni link con la seguente sintassi
<a h r e f = " h t t p : URL">testo</a>.
Queste informazioni sono utilizzate dai crawler per attraversare il grafo del web in
maniera sistematica ed efficiente. Infatti, vista la dimensione del grafo del Web, im-
proponibile generare tutti gli indirizzi a 32 o 128 bit dei possibili host di siti Web, per
accedere alle loro pagine. I crawler effettuano invece la visita del grafo del Web partendo
da un insieme S di pagine selezionate: in pratica, S viene formato con gli indirizzi di-
sponibili in alcune collezioni, come Open Directory Project, che contengono un insieme
di pagine Web raccolte e classificate da editori "umani". Quando un crawler scopre una
nuova pagina, la lista dei link in uscita da essa permette di estendere la visita a ulteriori
pagine (in realt la situazione pi complessa per la presenza di pagine dinamiche e di
formati diversi e, inoltre, per altre problematiche come la ripartizione del carico di lavoro
tra i vari crawler).
Possiamo quindi modellare il comportamento dei crawler come una visita di tutti i
nodi e gli archi di un grafo raggiungibili da un nodo di partenza, ipotizzando che i vertici
siano identificati con numeri compresi tra 0 e n 1 (quindi V = { 0 , 1 , . . . , n 1]) e il
numero di archi sia indicato con m = |E|. Osserviamo che gli algoritmi di visita discussi
nei seguito Utilizzino le pile e le code discusse finora e funzionano sia per grafi orientati
che per grafi non orientati.

7.4.1 Visita in ampiezza di un grafo


Abbiamo gi incontrato la visita in ampiezza nel caso degli alberi: tale tipo di visita, che
in tal caso prende il nome di BFS {Breadth-First Search), pu essere applicato anche a grafi
(di cui gli alberi sono un caso particolare), tenendo presente l'esigenza di evitare che la
presenza di cicli possa portare a esaminare ripetutamente gli stessi cammini. Nell'esporre
la visita in ampiezza su un grafo, faremo inizialmente l'ipotesi che l'insieme dei nodi sia
conosciuto: successivamente considereremo il caso pi generale in cui tale insieme non
sia preventivamente noto.
Al fine di esaminare ogni arco un numero limitato di volte (in particolare 2 o 1 in
dipendenza del fatto che il grafo sia orientato o meno) nel corso della visita, usiamo un
array booleano di appoggio r a g g i u n t o , tale che r a g g i u n t o [u] vale T R U E se e solo
se il nodo u stato scoperto nel corso della visita effettuata fino a ora. Il resto dello
BreadthFirstSearchC s ):
FOR (u = 0 ; u < N; u = U + 1)
raggiunto[u] = FALSE;
Q.Enqueue( s ) ;
WHILE (!Q.Empty( )) {
u = Q.Dequeue( ) ;
IF (!raggiunto [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;
Q.Enqueue( v ) ;
>
>

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: sup-
poniamo 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'ordi-
ne 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 inciden-
ti 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'ordi-
ne 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 (al-
trimenti 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 61
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 ; x != n u l i ; x=x.succ) {
v = x.dato;
Q.Enqueue( v ) ;
>
>

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 evidente-
mente 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 consen-
ta 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 di-
zionario 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 ese-
guita 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 M
BreadthFirstSearch

7.4.2 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 alter-
nativa 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 esami-
na tutti i vertici del grafo alla ricerca di quelli non ancora scoperti, invocando la ricorsio-
ne 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 necessa-
riamente 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, ripor-
tato nella Figura 7.3, dove sono mostrati sia l'albero BFS che l'albero DFS risultan-
ti. 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] = FALSE;
FOR (S = 0; s < 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 con-
durre 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 ver-
tice 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 preven-
tivamente 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 Paragra-
fo 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 pa-
gine 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 Applicazioni delle visite di grafi


7.5.1 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 in-
fatti 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'indie-
tro (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 otte-
nerlo, 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 utiliz-
zato 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 eredita-
riet 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 elettro-
nico. 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 at-
tivit 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 numerazio-
ne 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 ba-
se 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 vie-
ne 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 quan-
to, 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 asse-
gnato 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 nume-
razione 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

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 del-
l'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 Figu-
ra 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

Figura 7.6 Passo generico dell'algoritmo per l'individuazione delle componenti fortemente con-
nesse: quelle complete sono indicate in grigio chiaro mentre quelle parziali, attraver-
sate 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 macro-


vertici 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'in-
terno di due macro-vertici distinti sarebbero comunque collegati da cammini in entram-
be 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 Co-
dice 7.9. Ricordiamo che la visita mantiene implicitamente, e in ordine inverso, il cam-
mino 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 ba-
sato 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 at-
tualmente 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 com-
pletata 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 ver-


tice 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 numera-
zione 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'in-
dividuazione 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'ar-
ray 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 s = r0

Figura 7.7 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 inseri-
scono 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): altri-
menti (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 s = T0

"

Figura 7.8 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 1819) 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 valu-
tazione 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 (Fi-


gura 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 prio-
rit, 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, mo-
strandone 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 richie-
dono 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'or-
dinamento degli elementi nella lista stessa. Ad esempio, facendo riferimento alla parte
bassa della Figura 8.1, osserviamo che l'operazione Dequeue richiede soltanto di elimi-
nare 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 prio-
rit 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'organizza-
zione dell'insieme degli elementi in cui questi siano ordinati solo in parte.
Figura 8.2 Heaptree (a sinistra) e albero che non soddisfa la propriet di heap (a destra).

8.2 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 ini-
zialmente, comunque, descriveremo la sua struttura facendo riferimento a un'organizza-
zione 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 con-


tenuti 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'e-
lemento 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 si-
nistra: 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'al-
bero 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 altez-
za 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 inse-


rirla 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 scende-


re un elemento, impropriamente posto come radice, all'interno dello heap, fino a soddi-
sfare 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 mo-
do 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 sini-


stro 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'im-
plementazione 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 ope-
razioni 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): succes-
sivamente, 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 ):
Scambiai i, j ):
RETURN (i-l)/2; tmp=heapArray[i];
Sinistro( i ): heapArray[i]=heapArray[j];
RETURN 2 x i + 1; heapArray[j]=tmp;

Codice 8.2 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 610). 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 so-
no 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 au-
siliare. 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 rap-
presentano 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 ri-
sultante 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'introdu-
zione 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 mo-
dificare le operazioni E n q u e u e e S c a m b i a come riportato nel Codice 8.3. In par-
ticolare, 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 posizio-
ne 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 , da-
to 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 confron-
ti limitato inferiormente da logk = lg 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

lo
log(n!) > ^ log2n7i + n l o g n ^ n - y ^ + l " ) 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, percorrereb-
be 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 versio-
ne ristretta al solo secondo ciclo (righe 610), 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 rap-
presenta 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^ Osserva, sperimenta e verifica o


^Ip^ 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 conseguen-
za 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'iterazio-
n 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 con-
fronti 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 del-
le 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 propor-
zionale 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. In-
fatti, 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 suf-
ficiente dapprima inserire gli elementi in PQ, effettuando n operazioni E n q u e u e , e quin-
di 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 distan-
za 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 infor-
mazioni suddivise in pacchetti: anche se la quantit d'informazione da passare molto
elevata, come ad esempio nel caso di trasmissione di documenti video, tale informazio-
ne 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 ulte-
riore 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 que-
sto 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 se-
guente: 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 "sem-
plici" 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 dimen-
sione, 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'in-
frastruttura dei router come un grafo orientato pesato sugli archi, i cui nodi rappresen-
tano 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) ef-
fettuano la determinazione dei cammini minimi senza scambiare l'intero grafo tra i rou-
ter. 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 segui-
to: 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<j) = 35, corrispondente al cammino orientato
vi,v3,v 7 ,v 6 , mentre 6(vg,vi) = 57, corrispondente al cammino orientato v ( ;,v2,v5,v 1 .
In effetti, se il grafo non fortemente connesso pu avvenire che per due nodi u , v la
distanza (u, v) sia finita mentre 6(v, u) infinita: questo il caso ad esempio dei nodi
vii e v\2 nella Figura 8.4, per i quali 6(vn,vi2) = 12 mentre (vi2,vn) = +00, in
quanto non possibile raggiungere v n da V12.
Il problema che consideriamo quello che, dato un grafo pesato G = (V, E,W)
(orientato o meno) con funzione di peso W : E i-> 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 indi-
viduare 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 intro-
durremo 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 avreb-
be 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: com