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
Proprietà particolari
1) I cammini che partono dalla radice e arrivano ad una foglia (o viceversa) sono sempre
ordinati
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
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
Questa parte della procedura “INSERT” non può introdurre violazioni dell’ordinamento
parziale, perché:
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
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
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
Ω(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)
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 ≤
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)
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)
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
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.
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
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
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.
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.
IN QUESTO CASO m PUO’ ANCHE ESSERE UNA POTENZA DI DUE (anzi, è conveniente che lo sia
(pag.218 in basso) )
Complessità spaziale = O(m+n), con spesso n> m→ α >1, cioè le chiavi da memorizzare sono di più
rispetto alle posizioni disponibili in T
“Aperto” significa che ogni chiave può essere memorizzata in diverse posizioni
Caratteristiche:
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)
PSEUDOCODICE
MIGLIORAMENTI: si hanno dei miglioramenti solo nel caso medio (mentre nel caso pessimo non
migliora nulla rispetto alla concatenazione)
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!
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)
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
Caso induttivo: h ( x ) >0 →In questo caso x ha due figli (non entrambi NIL):
l=¿ ( x¿); r=¿ ( x )
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
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
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).
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)
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
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
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.
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.
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
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.