Sei sulla pagina 1di 48

ALGORITMI E STRUTTURE DATI

Heap
Un Heap (in inglese “mucchio”) è un albero (grafo connesso e aciclico) binario posizionale e
completo a meno dell’ultimo livello, il quale è riempito da sinistra verso destra.
Posizionale: ogni nodo figlio ha una posizione (può essere figlio destro o figlio sinistro)
Completo: ogni livello è riempito con il massimo numero possibile di nodi
Esistono due tipi di Heap, a seconda del modo in cui sono disposte le chiavi al suo interno:
 Min-Heap: la chiave del nodo padre è sempre minore o uguale delle chiavi dei figli
 Max-Heap: la chiave del nodo padre è sempre maggiore o uguale delle chiavi dei figli

Proprietà di Min-Heap: x ≤ y ; x ≤ z
Y Z Ordinamento parziale Max-Heap: x ≥ y ; x ≥ z

T y = sottoalbero del nodo Y


Ty Tz
Tz = sottoalbero del nodo Z

Proprietà particolari

1) I cammini che partono dalla radice e arrivano ad una foglia (o viceversa) sono sempre
ordinati

2) Per quanto riguarda i Min-Heap:


 Il nodo con chiave minima è la radice
 Il nodo con chiave massima sarà uno tra le foglie

3) Viceversa, per quanto riguarda i Max-Heap


 Il nodo con chiave massima è la radice
 Il nodo con chiave minima sarà uno tra le foglie
Motivazioni alle varie proprietà degli Heap
Le proprietà di completezza e di posizione dell’Heap sono legate all’implementazione fisica
dell’Heap stesso.
Solitamente, un Heap con N nodi viene implementato attraverso un array di N elementi.
L’array viene riempito inserendo al suo interno le chiavi dei nodi a partire dal livello più alto, da
sinistra verso destra. Proprio per questo motivo, al livello delle foglie queste ultime sono inserite
da sinistra verso destra (altrimenti ci sarebbero delle “posizioni vuote” all’interno dell’array)

4 5

7 8 10 11
1 2 3 4 5 6 7 8 9
2 4 5 7 8 10 11 12 15
12 15

Albero <-> Array

Come calcolare Parent o Figli del nodo X di indice “ i ”?


i i
- parent ( X )=⌊ 2 ⌋ = floor ( 2 ) <-> i≫ 1(shift a destra di una posizione)

- ¿( X ¿)=2 i <-> i≪ 1 (shift a destra di una posizione)

- ¿ ( X ) =2i+1 <-> ( i≪ 1 )∨1 (shift a destra di una posizione + OR con 1)

Oltre alla lunghezza length dell’array usato, che indica il numero massimo di nodi che è possibile
inserire all’interno dell’array/heap, esiste un’altra grandezza, che prende il nome di HeapSize, che
indica il numero di chiavi attualmente presenti all’interno dell’heap

Procedure con l’Heap


Problema: il problema principale legato alle procedure con gli Heap è mantenere intatte le
proprietà strutturali (albero completo, ultimo livello riempito da sinistra verso destra) e la
proprietà di ordinamento parziale. Tra le due, quella più difficilmente “ripristinabile” è quella
strutturale.
Soluzione al problema: solitamente le procedure legate all’Heap inseriscono i nodi di cui hanno
bisogno per funzionare nella prima posizione disponibile più a sinistra dell’ultimo livello (e in
questo modo la proprietà strutturale è già rispettata), e solo successivamente applicano la
procedura sull’Heap

Complessità delle procedure con l’Heap


Tutte le operazioni hanno nel caso pessimo una complessità linearitmica pari a O(n∗log n).
Nel caso ottimo invece per alcune procedure è possibile ottenere in particolari condizioni dell’input
una complessità lineare pari quindi a O(n)

PROCEDURE PRINCIPALI (nel caso di MIN-HEAP)


 Heapify: procedura che permette di verificare ed eventualmente
ristabilire la proprietà di ordinamento parziale all’interno
dell’Heap. Può essere applicata su un determinato nodo,
assumendo che i due sottoalberi radicati nei suoi figli siano Heap

Inserimento: quando si vuole inserire un elemento, le proprietà dell’heap devono essere


rispettate anche dopo che il nuovo nodo è stato inserito.
Idea: si inserisce il nuovo nodo tra le foglie (ultimo livello) nella prima posizione più a
sinistra. Per poi rispettare la proprietà dell’ordinamento parziale, si fa risalire il nodo
mentre si incontrano parent con un valore minore oppure la radice

Questa parte della procedura “INSERT” non può introdurre violazioni dell’ordinamento
parziale, perché:

Creazione heap con elementi dati ad 1 ad 1


1° passo: primo elemento = nuova radice
2° passo: verificare se la proprietà di ord.parziale è rispettata
3° passo: inserire nuovo elemento
4°….. passo: verificare se la proprietà di ord.parziale è rispettata

 Creazione heap con elementi dati in un array A: basta considerare l’array dato come un
Heap, e attraverso la procedura “Heapify” ripristinare la proprietà di ordinamento parziale.
In particolare si nota che:
o Le foglie sono banalmente degli Heap
o Basta applicare Heapify dal basso verso l’alto, da destra verso sinistra,senza
considerare le foglie. In questo modo, Heapify verrà applicata solo fino a quando:
heapsize
i < ⌊ ⌋
2

Estrazione e rimozione del minimo dall’heap: in un Min-Heap, il nodo con valore minimo si
trova certamente nella radice. Per mantenere invariate le proprietà di un Heap, è conveniente:
1. Scambiare la radice con la foglia più a destra, poiché quella è la posizione che se eliminata
non varia le proprietà strutturali dell’albero
2. Chiamare Heapify sulla radice per ristabilire eventualmente violazioni alla proprietà di
ordinamento parziale

Decremento di una chiave ad un certo valore K: si assume che k < A [i]

Il nodo potrebbe dover salire perché potrebbe assumere un valore minore del padre; quindi, si
usa la procedura vista nel caso dell’inserimento per far salire il nodo finchè non ci sono parent con
chiave minore o comunque al più fino alla radice
Al contrario, in questo caso il nodo non può mai dover scendere (quindi Heapify non serve)
perché sicuramente x < y e x< z ,e con il nuovo valore k, accade che k < x , e quindi in ogni caso
k ≤ y ek ≤ z

USO PRATICO DELL’HEAP: ORDINAMENTO HEAP-SORT


L’algoritmo di ordinamento Heapsort può essere pensato come un Selection Sort migliorato: come
nel Selection Sort, Heapsort divide il suo input in un'area ordinata e una non ordinata e riduce in
modo iterativo/ricorsivo l'area non ordinata estraendo l'elemento più grande (o piccolo) da esso e
inserendolo nell'area ordinata. A differenza del Selection Sort, l'Heapsort non effettua alcuna
scansione in tempo lineare della regione non ordinata, in quanto è sempre possibile accedere in
tempo costante all’elemento più grande/piccolo, che sarà ovviamente la radice dell’Heap.
La complessità è uguale al quicksort/mergesort, ma ha il vantaggio di lavorare in loco

PASSI DELL’ALGORITMO:
1. Trasforma l’array dato in un Max-Heap / Min-Heap con la procedura buildHeap(A,n)
2. Scambia il primo elemento (il più grande) con l’ultimo elemento (la foglia più a destra), e
diminuisce il valore di HeapSize di 1
3. Chiama la procedura Heapify sulla nuova radice, per evitare che la proprietà di
ordinamento parziale venga violata
4. Ripetere i passi 2 e 3 fino a quando HeapSize > 2
5. Appena Heapsize == 2, l’array A sarà stato ordinato

LIMITI DELL’ORDINAMENTO BASATO SU CONFRONTI


Ogni algoritmo di ordinamento basato su confronti non può fare meglio di n log n , quindi diciamo
che tutti gli algoritmi di ordinamento basati su confronti hanno limite inferiore pari a
n log n , e questo limite inferiore si indica così:

Ω(n log n)

Inoltre, l’algoritmo di ordinamento Heap-Sort ha complessità pari a n log n sia nel caso ottimo che
nel caso pessimo. Quindi si dice che l’Heap-Sort è:
Θ(n log n)

DIMOSTRAZIONE DEL PERCHE’ L’ORDINAMENTO BASATO SU CONFRONTI NON PUO’ AVERE


COMPLESSITA’ INFERIORE A n log n

Per dimostrarlo, si fa uso dei cosiddetti “alberi decisionali” o “alberi di decisione”. Essi sono degli
alberi binari
Inoltre, supponiamo che l’input contenga elementi distinti, e che per il confronto venga effettuata
l’operazione ≤

ANALISI DELLA FIGURA


Analizzando la figura, si nota che l’esecuzione dell’algoritmo di ordinamento corrisponde a
tracciare un cammino dalla radice ad una delle foglie. In particolare, le foglie rappresentano le
possibili permutazioni dell’array in input.
Quindi l’albero decisionale di un algoritmo di ordinamento, per considerare corretto quest’ultimo,
deve avere tra le foglie tutte le possibili n ! permutazioni dell’array in input. Indichiamo il numero
di foglie (in cui le permutazioni possono anche essere ripetute più volte) con L.
Nel caso peggiore, il cammino più lungo (e quindi il numero di confronti nel caso di ordinamento
peggiore) ha una lunghezza pari all’altezza dell’albero di decisione, che indichiamo con H.

DIMOSTRAZIONE DEL LIMITE INFERIORE n log n


Dato un albero decisionale con L foglie e N elementi in input da ordinare, si può vedere che
N !≤ L

Inoltre, un albero di altezza H possiede al più 2 H foglie. Quindi riscriviamo la relazione precedente
come:
H
N !≤L≤ 2
Calcolando i logaritmi, poiché la funzione log è monotona crescente, otteniamo:
H ≥ log ( N ! ) → si dimostra che tale relazione è ugualea Ω(n log n)

ORDINAMENTO IN TEMPO LINEARE


In alcuni particolari casi, gli algoritmi di ordinamento basati su confronti possono richiedere un
tempo lineare (es: array di partenza già ordinato). Tuttavia, questi sono dei casi isolati, che non
sono utili all’analisi asintotica degli algoritmi.

Esistono però degli algoritmi di ordinamento che hanno una complessità asintotica lineare.
Essi NON sono basati su confronti, bensì usano tecniche diverse per ordinare l’input.
È facilmente intuibile che un qualunque algoritmo di ordinamento non può fare meglio di Ω ( n ),
perché avendo un input di dimensione N è necessario almeno controllare gli N elementi dell’input
una volta.

COUNTING SORT
Il Counting sort è un algoritmo di ordinamento per valori numerici interi con complessità lineare.
L'algoritmo si basa sulla conoscenza dell'intervallo in cui sono compresi i valori da ordinare.
In particolare, se indichiamo con K la dimensione del range, allora se K è proporzionale a N allora il
Counting Sort è Θ(n)

SPIEGAZIONE FUNZIONAMENTO (VERSIONE NON STABILE, perché si salvano nell’array


ordinamento delle copie degli elementi originali)
L'algoritmo conta il numero di occorrenze di ciascun valore presente nell'array da ordinare,
chiamato A, e memorizza questa informazione in un array temporaneo C di dimensione pari
all'intervallo di valori k.

Successivamente, si calcolano i valori massimo, max ⁡(A ), e minimo, min ⁡(A ) , dell'array, in modo da
conoscere la dimensione del range di valori k, e si prepara un array ausiliario C di dimensione k,
con C [ i ] che rappresenta la frequenza dell'elemento i+min ( A )nell'array di partenza A. Si visita
l'array A aumentando l'elemento di C corrispondente. Dopo, si crea un ulteriore array B e si
scrivono su di esso C [ i ] copie del valore i+min ( A ).

VERSIONE STABILE
Per quanto riguarda la versione stabile del Counting Sort, supponiamo che nell’array C, alla
generica posizione C[i], anziché il numero di occorrenze del valore i+min ( A ), sia salvato il numero
di elementi minori di i+min ( A ). (Banalmente, in questo modo all’ultima posizione dell’array C
avremo il numero di elementi dell’array di partenza A)
A questo punto, per riempire l’array B, ogni volta che inseriamo un valore in quest’ultimo
dobbiamo diminuire di 1 la posizione dell’array C corrispondente all’elemento aggiunto in B.
In questo modo, gli elementi in B saranno i veri e propri elementi di A e non delle copie del loro
valore, quindi l’algoritmo diventa stabile.
Per completare la risoluzione del problema, si nota che leggendo inizialmente l’array A da sinistra
verso destra, la stabilità verrà mantenuta in B ma al contrario. Per ovviare a questo problema,
basta leggere l’array in input A da destra verso sinistra.

PSEUDOCODICE

PROBLEMI DEL COUNTING SORT


Oltre al fatto che nella sua versione originale il Counting Sort non è un algoritmo stabile, esso è in
tutte le sue versioni un algoritmo che NON lavora in loco, in quanto fa uso di diversi array
ausiliari.
Più in particolare, da ciò scaturiscono diversi problemi:
- Anche se la dimensione dell’array iniziale A è piccola, la dimensione dell’array ausiliario B
dipende in realtà dal range di valori all’interno di A: se il range fosse molto ampio, potrebbe
capitare che la dimensione di B sia di molto maggiore rispetto alla dimensione di A.
Per questo motivo, solitamente non si cerca solo il massimo (e far iniziare da 0 il range di valori)
ma si cerca anche il minimo (in modo da diminuire il range)

TAVOLE DI INDIRIZZAMENTO DIRETTO E TABELLE HASH


Spesso è necessario usare un particolare insieme di dati in cui è necessario mettere in
corrispondenza una chiave e un valore, che prende il nome di Dizionario, sul quale si vogliono
effettuare le operazioni di inserimento, cancellazione e ricerca.

IMPLEMENTAZIONE
Vediamo quali sono le complessità delle operazioni necessarie se effettuate con le strutture dati
più comuni:

Nessuna di queste strutture dati è ottimale. Per questo motivo, si utilizza un nuovo tipo di struttura
che prende il nome di Tabella di Hash.

TAVOLA A INDIRIZZAMENTO DIRETTO


Una prima versione molto semplice di tabella di hash sono le cosiddette tavole a indirizzamento
diretto.
Supponiamo di avere un insieme di chiavi da rappresentare S di dimensione n, e un insieme
universo delle chiavi U di dimensione m. Una tavola a indirizzamento diretto è un array T di
dimensione m, in cui alla posizione di indice i sono contenuti i valori:
 1, se la chiave i∈ S
 0 altrimenti
 un valore n che indica il numero di duplicati i∈ S

OPERAZIONI
PROBLEMA: l’uso dell’indirizzamento diretto è utile quando l’insieme universo U non è molto
grande (visto che in T abbiamo una locazione per ogni possibile chiave da rappresentare, presente
in U), anche perché molte delle chiavi di U potrebbero non dover essere rappresentate.
Per risolvere questo problema, si usano le cosiddette tabelle hash.

TABELLE HASH
Le tabelle di hash sono una struttura dati che risolve in maniera diversa il problema della
realizzazione di un dizionario. In particolare, data una chiave k, essa verrà memorizzata in una
posizione ottenuta applicando una funzione h, che prende il nome di funzione di hash, definita:
h :U →(0,1 , … , m−1), dove generalmente n ≤ m≪ ≪≪∨U ∨¿
Quindi la posizione della chiave k sarà data da h(k).
Più nello specifico, h è una funzione deterministica, ossia l’associazione chiave-posizione è
unica / data una chiave k, h(k) restituisce sempre lo stesso risultato (MA NON E’ DETTO CHE a
chiavi distinte assegni posizioni distinte = problema delle COLLISIONI)
PROBLEMA DELLE “COLLISIONI”
Il problema principale legato alle tabelle hash sono le cosiddette collisioni: una collisione avviene
nel momento in cui esistono due chiavi k 1 e k 2 tali che h ( k 1) =h(k 2) . In questo caso avviene una
collisione.

Il problema delle collisioni si verifica perché |U| > m; quindi, non può esserci corrispondenza
biunivoca tra chiavi e posizioni

RISOLUZIONE DELLE “COLLISIONI”


Per risolvere il problema delle collisioni ci sono due modi: concatenazione e indirizzamento
aperto.

RISOLUZIONE DELLE “COLLISIONI” PER CONCATENAZIONE


Nel caso della concatenazione, ogni posizione di T contiene il riferimento ad una lista linkata, che
contiene tutte le chiavi associate a quella determinata posizione
FATTORE DI CARICO
Il “fattore di carico”, indicato con “α ” indica letteralmente “quanto la tabella è appensatita”, cioè
qual è indicativamente la distribuzione delle chiavi all’interno della tabella T.
È dato dal rapporto tra il numero n delle chiavi da rappresentare e il numero di locazioni
disponibili m nella tabella T.
n
α=
m

CASI PARTICOLARI
α =0 → significa che n = 0, cioè la tabella T è vuota

α =1 → significa che n = m, cioè la tabella T è piena (assumiamo con chiavi tutte diverse)

m
α =0.5 →significa che n = , cioè la tabella T è piena per metà
2
α >1→significa che n > m, cioè avverranno sicuramente delle collisioni

CASO MEDIO
Il caso medio si verifica quando una funzione hash distribuisce uniformemente le chiavi all’interno
della tabella T.
Supponiamo che qualsiasi elemento abbia la stessa probabilità di essere assegnato ad una
1
qualsiasi posizione tra le “m” posizioni disponibili. Quindi, questa probabilità sarà pari a .
m

Questa ipotesi prende il nome di “IPOTESI DI HASHING UNIFORME SEMPLICE”


Adesso ipotizziamo di dover rappresentare n chiavi (con n = |S|), e considerando una lista
qualunque T[i], con 0 < i < m.
Il valore atteso della lunghezza della lista T[i] sarà:
n
E [ T [ i ] ] = =α
m

Facendo queste ipotesi e considerazioni, se quindi si riuscissero a distribuire uniformemente tutte


le chiavi all’interno della tabella, le operazioni avrebbero complessità proporzionale ad α

FUNZIONE HASH
Una buona funzione hash dovrebbe soddisfare l’ipotesi di hashing uniforme semplice, cioè tutte le
chiavi devono avere la stessa probabilità di finire in una qualsiasi delle m celle di T,
indipendentemente dal posizionamento delle altre chiavi.
Tuttavia, difficilmente questa ipotesi può essere rispettata, in quanto la distribuzione delle chiavi
raramente è resa nota.
Esistono due metodi di generazione diversi: il METODO DELLA DIVISIONE e il METODO DELLA
MOLTIPLICAZIONE.

METODO DELLA DIVISIONE


Il metodo della divisione consiste semplicemente in una divisione modulare.
Più nello specifico, avendo m locazioni in T, la generica posizione K sarà associata alla posizione:
h ( k )=k mod m

PROBLEMA: con il metodo della divisione, alcuni valori di m potrebbero creare problemi: ad
esempio, se “m” è una potenza di 2, allora m=2 p , e quindi k mod 2 p restituisce i p bit meno
significativi di m. Potrebbe quindi capitare che certe chiavi, che guarda caso terminano con gli
stessi p bit, creerebbero delle collisioni.

METODO DELLA MOLTIPLICAZIONE


Il metodo della moltiplicazione si svolge in due passi:
1. Si moltiplica la chiave K da rappresentare per una costante A compresa tra 0 e 1 esclusi (
0< A <1) e si estrae la parte frazionaria di questo prodotto, attraverso l’operazione
kA mod 1
2. Moltiplichiamo il risultato ottenuto al passo 1 per m, e prendiamo la parte intera “inferiore”
del risultato (floor)
La funzione Hash completa è quindi: h ( k )=⌊ m ( kA mod 1 ) ⌋ = floor (m ( kA mod 1 ))

IN QUESTO CASO m PUO’ ANCHE ESSERE UNA POTENZA DI DUE (anzi, è conveniente che lo sia
(pag.218 in basso) )

PROBLEMA DELLA RISOLUZIONE DELLE COLLISIONI PER CONCATENAZIONE


Il principale problema è dato dal fatto che le chiavi non sono memorizzate realmente all’interno
della tabella, bensì è necessario allocare lo spazio necessario a memorizzare le varie chiavi
all’interno di liste linkate esterne alla tabella Hash, che conterrà quindi solo i riferimenti alle
varie liste.

Complessità spaziale = O(m+n), con spesso n> m→ α >1, cioè le chiavi da memorizzare sono di più
rispetto alle posizioni disponibili in T

RISOLUZIONE DELLE COLLISIONI con INDIRIZZAMENTO APERTO

“Aperto” significa che ogni chiave può essere memorizzata in diverse posizioni

Caratteristiche:

 Gli elementi sono memorizzati all’interno della tabella: n ≤ m SEMPRE → 0 ≤ α ≤1


 Funzione Hash: se la posizione restituita dalla funzione hash è già occupata, essa restituirà
un’altra posizione

FUNZIONE HASH

La funzione hash in questo caso se restituisce per una certa chiave una posizione occupata
restituirà un’altra posizione, e così via fino a quando non si trova una posizione vuota oppure la
tabella è piena.

In questo caso la funzione hash prende come parametro anche il “numero di tentativo” i, e inoltre
assumiamo che restituisca posizioni diverse ad ogni diverso tentativo:
h ( k , i ) ≠ h ( k , j ) , se i≠ j

Chiaramente il massimo numero di tentativi è m (num. posizioni in T), quindi i tentativi vanno da 0
a m-1, ed inoltre anche i valori restituiti variano da 0 a m-1. Quindi h ( k , i ) è definita come:
h ( k , i ) : S ( Insieme delle chiavi )∗{ 0,1 , … , m−1 } → { 0,1 , … ,m−1 }

Inoltre, la funzione deve anche in questo caso essere deterministica; quindi, per ogni chiave ki
deve restituire sempre la stessa sequenza di posizioni. Ad ogni funzione hash è quindi associata
una sequenza di valori che prende il nome di SEQUENZA DI SCANSIONE. In particolare, una
sequenza di scansione sarà una possibile permutazione dell’insieme { 0,1 , … , m−1 }.
Avremo quindi m! diverse sequenze di scansione (numero di permutazioni)

SEQUENZA DI SCANSIONE GENERICA: ¿ h ( k , 1 ) ,h ( k ,2 ) , … , h(k , m−1)>¿

PSEUDOCODICE
MIGLIORAMENTI: si hanno dei miglioramenti solo nel caso medio (mentre nel caso pessimo non
migliora nulla rispetto alla concatenazione)

COSTRUZIONE DELLA FUNZIONE HASH


Come nel caso della concatenazione, anche in questo caso ci si basa su una particolare ipotesi, che
prende il nome di IPOTESI DI HASHING UNIFORME.

IPOTESI DI HASHING UNIFORME: una qualsiasi delle possibili sequenze di scansione è associata
con la stessa probabilità a una qualsiasi delle chiavi.

Sia P una generica sequenza di scansione. La probabilità che P sia assegnata ad una qualunque
chiave K è:
1
P R{“P è assegnata a K”} =
m!

ESEMPI DI FUNZIONI HASH

PROBLEMA: anche la scansione quadratica soffre del problema dell’agglomerazione (in questo
caso detto “problema dell’agglomerazione secondaria”)
SOLUZIONE: anziché una, consideriamo due funzioni hash ausiliarie h' , h' ' (“TECNICA
DELL’HASHING DOPPIO”)

ANALISI DEL CASO MEDIO DELLE OPERAZIONI (supposto che valga la “proprietà di hashing
uniforme”)

 Analisi “RICERCA SENZA SUCCESSO” (la ricerca si ferma appena trova una cella vuota)

Analisi “RICERCA CON SUCCESSO”


ALBERI ROSSONERI
Un albero rosso-nero (Red-Black Tree – RB tree) è un albero binario di ricerca in cui ad ogni nodo
associamo un colore, che può essere rosso (Red) o nero (Black).
Vincolando il modo in cui possiamo colorare i nodi lungo un qualsiasi percorso che va dalla radice
ad una foglia, riusciamo a garantire che l’albero sia approssimativamente bilanciato. Per questo
motivo, gli alberi rosso-neri possono essere definiti come alberi auto-bilanciati.
Ogni nodo dell’albero è caratterizzato da cinque campi: color, key, left, right e p.

PROPRIETA’ DI UN ALBERO ROSSO-NERO


Un RB tree è un albero binario di ricerca che soddisfa le seguenti proprietà:
1. ogni nodo è rosso o nero
2. la radice è nera
3. ogni foglia è nera (per rispettare questa proprietà è anche possibile aggiungere dei nodi
NIL fittizi neri)
4. se un nodo è rosso, entrambi i suoi figli devono essere neri
5. per ogni nodo n, tutti i percorsi che vanno da n alle foglie sue discendenti contengono lo
stesso numero di nodi neri

MOTIVO PER CUI UN ALBERO ROSSO-NERO È AUTO-BILANCIATO


Per la proprietà 5 (tutti i cammini da un nodo x alle foglie hanno lo stesso numero di nodi neri):
 CASO BASE: un RB tree senza nodi rossi e con solo nodi neri deve essere bilanciato, in
quanto ci sarà sempre lo stesso numero di nodi (proprietà 5)
 CASO DI NODI ROSSI E NERI: si potrà avere un ramo più “lungo” e uno più “corto”. Ma la
differenza tra i due rami sarà al più del doppio del numero dei nodi del ramo, poiché
almeno la metà dei nodi deve per forza essere nera. Quindi, poiché l’altezza del ramo più
corto è uguale a log n , allora la lunghezza del ramo più lungo sarà al più di 2 log n (quindi
sempre logaritmica).
Possiamo quindi dire che per non aumentare l’altezza dell’albero inseriamo al più un numero
di nodi rossi uguale al numero di nodi neri.

ALTEZZA NERA / BLACK HEIGHT


Considerato un generico nodo x, definiamo bh ( x ) = numero di nodi neri (x escluso) nel cammino
da x ad una foglia
CASO PARTICOLARE: bh ( root ) = black-height dell’albero

TEOREMA: l’altezza massima di un albero rosso-nero con n nodi interni è 2 log (n+ 1)
LEMMA: il numero di nodi interni di un sottoalbero radicato in un generico nodo x è maggiore o
uguale a 2bh( x)−1

DIMOSTRAZIONE DEL LEMMA: fatta per induzione sull’altezza h dell’albero radicato in x


Caso base: h ( x )=0 →In questo caso x è una foglia e bh ( x )=0 , ed il sottoalbero radicato in x ha 0
nodi interni. Inoltre:
2bh( x)−1=1−1=0

Caso induttivo: h ( x ) >0 →In questo caso x ha due figli (non entrambi NIL):
l=¿ ( x¿); r=¿ ( x )

Inoltre, valgono anche le seguenti relazioni:


bh ( l ) ,bh ( r ) ≥ bh ( x ) −1 (per le proprietà dell’altezza nera)

Per ipotesi induttiva:

∫ ( l ) ≥2 bh( x )−1 ≥ 2bh( x )−1−1 E ∫ ( r ) ≥ 2bh (x )−1≥ 2bh ( x)−1−1


Allora:

∫ ( x ) ≥1+∫ ( l ) +∫ ( r ) ≥ ( 1+ 2bh( x)−1−1 ) +(2¿¿ bh ( x )−1−1)=2 ¿ 2bh( x )−1−1=2bh (x )−1¿

DIMOSTRAZIONE DEL TEOREMA


Sia h l’altezza dell’albero. Qualsiasi cammino dalla radice a una foglia contiene almeno metà nodi
h
neri, quindi almeno nodi neri.
2
h
Da ciò possiamo dedurre che: bh ( T )=bh ( root ) ≥ .
2
h h
Per il lemma precedente: n ≥ 2bh( T )−1≥ 2 2 −1 e n+1≥ 2 2 .

h
Quindi: log (n+1)≥ → h≤ 2 log ( n+1 )
2
ROTAZIONI IN UN BST
Sono delle operazioni di ristrutturazione locale dell’albero che mantengono soddisfatte le
proprietà degli alberi binari di ricerca (e in particolare quindi anche degli alberi rossoneri)
OPERAZIONI ALBERI ROSSONERI
INSERIMENTO
Esattamente come per gli alberi binari di ricerca, l’inserimento di un nodo z in un RB tree cerca un
cammino discendente dalla radice dell’albero fino al nodo y che diventerà suo padre: una volta
identificato il padre y, z viene aggiunto come figlio sinistro (destro) di y se key[z] ≤ key[y] (key[z]
≥ key[y]).
Tuttavia, nel caso di RB tree ci sono dei problemi di cui tener conto, legati soprattutto alle
proprietà di questi ultimi:
 Quale colore associamo al nuovo nodo z? Per non violare la proprietà 5 (tutti i cammini da
un qualsiasi nodo alle foglie sue discendenti hanno lo stesso numero di nodi neri) il colore
di z al momento dell’inserimento deve essere rosso.

 Questo ovviamente può causare la violazione di altre proprietà dei RB tree: ad esempio se
color[y] = rosso violiamo la proprietà 4. In questo caso eseguiamo delle rotazioni e delle
ricolorazioni per ristabilire le proprietà violate.

CASO BASE (inserimento di un nuovo nodo il cui padre è nero): possiamo semplicemente
aggiungere il nuovo nodo colorato di rosso, e ciò non modificherà alcuna proprietà dei RB-tree

3 CASI (in cui anche il padre di z è rosso):

1. Lo zio di Z è rosso: Il padre e lo zio di z – entrambi rossi – vengono colorati di nero; il nonno
di z – che era nero – viene colorato di rosso.

2. Lo zio di Z è nero, e Z è un figlio sinistro: si scambiano i colori del padre e del nonno – che
diventano rispettivamente rosso e nero – ed effettuiamo una rotazione a destra sul padre di
z

3. Lo zio di Z è nero, e Z è un figlio destro: ci si riconduce al caso 2 mediante una rotazione a


sinistra effettuata sul padre di z
CANCELLAZIONE
Come nel caso di un BST, quando si deve cancellare un nodo:
 Se il nodo è una foglia, si cancella il nodo stesso e basta
 Se il nodo ha un solo figlio, si cancella il nodo e al suo posto viene messo il figlio
 Se il nodo ha due figli, si scambia la chiave del nodo da cancellare con il suo successore
(che avrà certamente al più un solo figlio) e poi si cancella il nodo da cancellare nella sua
nuova posizione.

PROBLEMA: nel caso di un RB-Tree, la cancellazione di un nodo può provocare una violazione della
proprietà 5 (per ogni nodo n, tutti i percorsi che vanno da n alle foglie sue discendenti contengono
lo stesso numero di nodi neri).

PROCEDIMENTO DI CANCELLAZIONE (z=nodo da cancellare, x=figlio[z] , y=padre[z]: w=fratello[x])


1. Rimuoviamo z e colleghiamo x a y
2. Se z era rosso, allora y e x per la proprietà 2 devono per forza essere neri: FINE
3. Se z era nero, è possibile che sia stata violata la proprietà. Allora:
a. Se x è rosso allora coloriamo x di nero
b. Se x è nero, coloriamo y con un colore fittizio, detto doppio nero (serve a ricordare
che la proprietà 5 è stata violata)
c. Ripristino delle violazioni alla proprietà 5

Per ripristinare le violazioni della proprietà 5 si agisce ancora una volta per casi:
1. Il fratello w di x ha almeno un figlio rosso. Sottocasi:
a. Il figlio rosso di w è un figlio destro
b. Il figlio rosso di w è un figlio sinistro
2. Il fratello w di x è nero ed entrambi i suoi figli sono neri
3. Il fratello w di x è rosso
RISOLUZIONE CASI

CASO 1a: scambiamo il colore di w (nero) con quello di y (che può essere sia nero che rosso) e
ruotiamo y a sinistra. Dopo la rotazione a sinistra, il nodo y – che adesso è nero – si trova a sinistra
dell’albero: questo significa che, a causa della rotazione, nel sottoalbero sinistro viene aggiunto un
nodo nero. Per ribilanciare il numero di nodi neri nel sottoalbero destro basta rendere nera la root
di α. In questo modo possiamo rimuovere il doppio nero da x rendendolo “regolarmente” nero
senza violare altre proprietà.

CASO 1b: Può essere ricondotto al caso 1a scambiando il colore di w con quello della root t del suo
figlio sinistro (che sappiamo essere rossa) e ruotando t a destra. A questo punto possiamo eseguire
le ricolorazioni/rotazioni descritte per il caso 1.1 (notare che in questo caso α `e il sottoalbero la
cui root `e w) e rimuovere così il doppio nero.
CASO 2: Poichè anche w è nero, togliamo un nero sia da x che da w lasciando x con un solo nero e
w rosso. Per compensare la rimozione di un nero sia da x che da w coloriamo il y (che
originariamente era rosso oppure nero) con un doppio nero. A questo punto y diventa il nuovo x

CASO 3: Poichè w è rosso, suo padre y ed i suoi figli (le root degli alberi α e β) devono essere neri
Possiamo scambiare il colore di w con quello di y e ruotare y a sinistra senza violare nessuna delle
proprietà red-black. A questo punto x è sceso di un livello a sinistra ed ha un nuovo fratello (la root
di α) che è nero; abbiamo trasformato questo caso in uno dei casi precedenti
VISITE DEI GRAFI
Esistono due tipi di visite sui grafi:
 Visita in ampiezza o BFS (Breadth-First Search)
 Visita in profondità o DFS (Depht-First Search)

Visita in ampiezza o BFS


Dato un grafo G=(V,E) e un vertice s, detto sorgente, la visita in ampiezza ispeziona
sistematicamente gli archi del grafo per “scoprire” tutti i vertici raggiungibili dalla sorgente s.
Inoltre, con lo stesso algoritmo è possibile:
1. calcolare la distanza (cioè il minimo numero di archi) tra s e un altro vertice del grafo
raggiungibile da s.

2. generare un cosiddetto “BF-tree” che ha come radice la sorgente s, e contiene tutti i vertici
raggiungibili da s. Per ogni vertice v raggiungibile da s, il cammino semplice che va da s a v
all’interno del BF-tree indica il cammino minimo tra v ed s nel grafo di partenza G

L’algoritmo di visita in ampiezza è chiamato così perché visita prima i nodi adiacenti alla
sorgente, poi visita “gli adiacenti agli adiacenti”, e così via… in maniera formale, diciamo che in
una BFS “l’algoritmo scopre tutti i vertici che si trovano a distanza k da s, e solo
successivamente scopre quelli che si trovano a distanza k+1, ecc…”

Per tenere traccia del lavoro svolto, l’algoritmo colora i vertici in 3 modi, a seconda del loro
“stato”:
 Bianco: vertici non ancora visitati/scoperti
 Grigio: vertici per cui è in corso una visita
 Nero: vertici la cui visita è stata completata
La visita costruisce piano piano il BF-tree che ha per radice la sorgente s. Quando un vertice bianco
v viene scoperto ispezionando la lista di adiacenza di un vertice u già scoperto, il vertice v e l’arco
(u,v) vengono inseriti nel BF-tree. In particolare:
 u è detto predecessore di v
 v è detto discendente di u. v inoltre può avere un unico antenato, in quanto un vertice
viene scoperto in una BFS un’unica volta.

N.B. POTREBBERO ANCHE ESSERCI DEI NODI CHE NON VERRANNO MAI VISITATI. IN QUEL CASO
ESSI NON AVRANNO UN PREDECESSORE, E LA LORO DISTANZA DALLA SORGENTE SARA’ ∞
PSEUDOCODICE della visita BFS
Visita in profondità o DFS

Dato un grafo G=(V,E), la visita in profondità ispeziona gli archi a partire dall’ultimo vertice
scoperto “v” che ha ancora archi non ispezionati. Quando tutti gli archi di v sono stati ispezionati,
la visita “torna indietro” e ispeziona gli archi uscenti dal vertice da cui era stato scoperto v, e così
via si torna indietro fino a quando non ci sono più archi da ispezionare e tutti i vertici del grafo
saranno stati visitati.
A differenza della visita BFS, tutti i nodi del grafo sono visitati: questo perché nel momento in cui
non ci sono più archi ispezionabili, la procedura controlla comunque se tutti i vertici del grafo
sono stati visitati, e solo in quest’ultimo caso la visita termina.
Per questo motivo, spesso una visita DFS produce diversi alberi DF, i quali danno vita ad una
cosiddetta foresta DF.
Per tenere traccia del lavoro svolto, come nel caso della BFS, l’algoritmo colora i vertici in 3 modi, a
seconda del loro “stato”:
 Bianco: vertici non ancora visitati/scoperti
 Grigio: vertici per cui è in corso una visita
 Nero: vertici la cui visita è stata completata

N.B. Ciò garantisce il fatto che un nodo v viene visitato una solta volta, e di conseguenza gli alberi
DF saranno disgiunti, cioè non avranno mai vertici in comune.

ALTRE UTILITA’
La DFS per ogni nodo v ∈V tiene traccia di:
 d [ v ] : tempo di inizio visita ⇔ v è colorato di GRIGIO
 f [ v ] : tempo di fine visita ⇔ v è colorato di NERO
 π [ v ] : predecessore di v

CLASSIFICAZIONE DEGLI ARCHI


A partire da un nodo sorgente v la cui visita è in corso (cioè v è grigio), è possibile classificare gli
archi uscenti da v a seconda del colore del vertice v verso cui vanno gli archi:
 ¿ [u¿]=gray → “arco all’indietro” → c’è un ciclo
 ¿ [u¿]=white →“arco dell’albero” → ù è figlio di v
 ¿ [u¿]=black : 2 casi, dipendenti da d [ v ] , d [ u ] , f [ v ] e f [ u ]:
TABELLA RIASSUNTIVA DEI TEMPI DI VISITA

PSEUDOCODICE della visita BFS


APPLICAZIONI DELLA VISITA DFS

ORDINAMENTO TOPOLOGICO
Un ordinamento topologico di un grafo consiste in un “ordinamento lineare dei nodi di un grafo
orientato che rispetta una particolare proprietà”
PROPRIETA’: dati due nodi u , v ∈V , se ∃ ( u , v ) ∈ E , allora u viene prima di v nell’ordinamento
topologico.
Per questa proprietà, disegnando i nodi secondo l’ordinamento topologico ottenuto, gli archi
andranno tutti da sinistra verso destra.
Un grafo può:
1) Avere uno o più ordinamenti topologici possibili
2) Non avere alcun ordinamento topologico. Ciò accade quando nel grafo è presente un ciclo
(che ci costringe a disegnare archi da destra verso sinistra)
N.B. Se ci sono nodi isolati, essi possono essere posizionati ovunque nella sequenza di
ordinamento

NUMERO MASSIMO DI ORDINAMENTI TOPOLOGICI

Avendo un grafo di n nodi, e potendo disporre a piacere gli archi del grafo, abbiamo n! possibili
ordinamenti topologici.
n!
Se si aggiunge un “arco fisso”, avremo possibili ordinamenti topologici
2
n!
Se si aggiunge un altro “arco fisso”, avremo 4 possibili ordinamenti topologici

E cosi via…
Nel momento in cui si aggiungerà al grafo un arco tale da creare un ciclo, allora i possibili
ordinamenti topologici saranno 0.
ALGORITMO DELL’ORDINAMENTO TOPOLOGICO
1) ALGORITMO BANALE: per ogni nodo, si calcola il numero di archi entranti e uscenti.
Inizialmente, si prende/prendono il/i nodo/nodi con un numero di archi entranti pari a 0 (se
c’è più di un nodo con questa caratteristica, si sceglie un nodo a piacere), ed esso/essi
sarà/saranno i primi nodi dell’ordinamento. Successivamente, si eliminano gli archi uscenti
da questo/questi nodo/nodi, e si ripete da capo il processo con i nodi rimanenti

2) ALGORITMO CON DFS: si va una visita DFS sui nodi dell’albero, e poi si prendono i nodi
ordinandoli in maniera inversa secondo i tempi di fine visita (i primi saranno i nodi il cui
valore di fine visita è più alto, cioè i nodi la cui visita è terminata per ultima).
E’ possibile controllare la presenza di un ciclo direttamente nella visita: basta verificare che
non si incontrino nodi grigi.

CALCOLO COMPONENTI CONNESSE (grafo non orientato)


Componente connessa: sottoinsieme massimale (cioè non ulteriormente “espandibile”) che
rispetta la proprietà della mutua raggiungibilità (dati due nodi u e v, è possibile andare sia da u a
v che da v ad u)

CALCOLO COMPONENTI FORTEMENTE CONNESSE (grafo orientato)

ALGORITMO: nel caso di un grafo non orientato, basta semplicemente effettuare una visita DFS
del grafo. Ogni volta che la visita si interrompe per mancanza di vertici adiacenti da visitare (e
quindi “passa ad un altro vertice a parte”), ciò significa che è stata individuata una componente
connessa.
È quindi possibile dire che il numero di componenti connesse è uguale al numero di volte che la
visita si interrompe, o ancora che il numero di componenti connesse è uguale al numero di alberi
della foresta DFS.

Questo algoritmo si basa su due concetti:


 Grafo trasposto: esso è il grafo di partenza in cui l’orientamento degli archi è invertito (
G ={( u , v ) : ( v , u ) ∈ E } )
T

 Grafo delle componenti: esso è il grafo ottenuto sostituendo ad ogni componente


connessa un vertice

ALGORITMO CALCOLO COMPONENTI FORTEMENTE CONNESSE


ESEMPIO CALCOLO COMPONENTI FORTEMENTE CONNESSE
CAMMINI MINIMI DA SORGENTE UNICA
Un primo modo per calcolare i cammini minimi era quello di usare una semplice visita BFS.
Tuttavia, questo algoritmo funziona solo se gli archi hanno tutti peso unitario (cioè non c’è alcuna
funzione che associa un peso agli archi/vertici). E’ necessario quindi un algoritmo che calcoli la
distanza di cammino minimo quando è definita una funzione peso ω ( p) : E → R , la quale ad ogni
cammino “p” di lunghezza “k” assegna un costo:
k
ω ( p ) =∑ ω(V i−1 , V i)
1

Dati due nodi u e v, indichiamo con δ ( v ,u )la distanza di cammino minimo tra i vertici “v” e “u”, o
analogamente il costo minimo tra tutti i cammini esistenti da “v” ad “u”.
N.B. Se non esiste un cammino che va da v a u, allora δ ( v ,u )=+∞ .
Dal punto di vista matematico:

{
δ ( v ,u )= min(ω (p): p è un cammino da v ad u , se ∃un cammino da v ad u)
+∞ , negli altri casi

PROBLEMA: ARCHI DI PESO NEGATIVO


In alcuni casi la funzione peso assegna agli archi valori negativi. Si possono verificare allora
situazioni diverse a seconda del fatto che il grafo possieda o meno dei cicli che coinvolgono archi
con pesi negativi:
1. G(V,E) non contiene cicli con archi con peso negativo: il calcolo dei cammini minimi può
essere effettuato normalmente.
2. G(V,E) contiene cicli con archi con peso negativo: in questo caso il calcolo dei cammini
minimi è impossibile, in quanto se ci fosse un ciclo che comprende archi con peso negativo,
allora si potrebbe percorrere all’infinito il ciclo diminuendo sempre di più il costo del
cammino all’infinito.
Se esiste un ciclo con archi con peso negativo da v ad u, allora δ ( v ,u )=−∞

PROCEDURA “INITIALIZE SINGLE SOURCE”


Questa procedura è usata per inizializzare alcuni valori particolari, utili per il funzionamento
dell’algoritmo per il calcolo dei cammini minimi.

PROCEDURA “RELAX” / “RILASSAMENTO”


La procedura di rilassamento di un arco consiste nel vedere se, passando da un vertice “u”, è
possibile migliorare la precedente stima di cammino minimo dalla sorgente “s” a “v”.
In caso affermativo, la procedura diminuisce la stima di cammino minimo e aggiorna il
predecessore di “v”.
N.B. All’interno del vertice è contenuta la stima di cammino minimo attuale.

ALGORITMO PER IL CALCOLO DEI CAMMINI MINIMI IN MANIERA BANALE / “BRUTE-FORCE”

ALGORITMO PER IL CALCOLO DEI CAMMINI MINIMI IN UN GRAFO ORIENTATO ACICLICO (“DAG”)
Per ottimizzare il precedente algoritmo, sfruttiamo l’ordinamento topologico.
Dati due vertici u e v, se esiste un cammino minimo da u a v, allora “u” precede “v”
nell’ordinamento topologico.
L’algoritmo, quindi, prima calcola un ordinamento topologico dei vertici, e chiama la procedura di
rilassamento sui vertici adiacenti a quello incontrato nell’ordinamento topologico, a partire dal
primo.
ALGORITMO DI DIJKSTRA
L’algoritmo di Dijkstra risolve il problema dei cammini minimi in un grafo orientato pesato con
tutti i pesi non negativi: ω ( u , v ) ≥ 0 ∀ ( u , v ) ∈ E .
L’algoritmo mantiene in un insieme S i cosiddetti vertici a convergenza, cioè quei vertici per cui è
già stato calcolato il peso del cammino minimo dalla sorgente.
In pratica, l’algoritmo:
1. Seleziona il vertice u ∈V −S con la stima minore
2. Aggiunge il vertice u selezionato all’insieme s
3. Rilassa tutti gli archi uscenti da u
N.B. L’estrazione al passo 1 viene facilitata grazie all’implementazione di una coda di priorità e
alla successiva chiamata della funzione EXTRACT_MIN (…)
ALGORITMO “BELLMAN-FORD”
L’algoritmo di Bellman-Ford è un algoritmo di ricerca dei cammini minimi da sorgente singola nel
caso generale: è infatti ammessa la presenza di cicli e pesi negativi.
L’idea generale è quella che, dato un grafo di partenza, si riesca a rilassare tutti gli archi del
cammino nell’ordine in cui essi appaiono all’interno del cammino stesso; più nello specifico,
all’n-esimo passo dell’algoritmo si vuole che vengano rilassati gli archi n-esimi di ognuno dei
cammini minimi del grafo
CAMMINI MINIMI DEL GRAFO

Ovviamente, non sappiamo a priori “dove” si trovano questi cammini minimi all’interno del grafo.
Una soluzione è quella di rilassare tutti gli archi del grafo: in questo modo sicuramente
rilasseremo anche gli archi di nostro interesse.

PROBLEMA PRINCIPALE: non conosciamo a priori la lunghezza massima tra tutti i cammini minimi,
quindi non sappiamo il numero di volte che dovremo applicare l’operazione di rilassamento agli
archi del grafo.
Tuttavia, sappiamo che in un grafo di n nodi ci sono al più n-1 archi: di conseguenza, la lunghezza
di un cammino minimo (semplice) non sarà mai maggiore di n.
Di conseguenza, per rilassare tutti gli archi del grafo, bastano n-1 operazioni di rilassamento.

Potrebbero piacerti anche