Docsity
Docsity

Prepara i tuoi esami
Prepara i tuoi esami

Studia grazie alle numerose risorse presenti su Docsity


Ottieni i punti per scaricare
Ottieni i punti per scaricare

Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium


Guide e consigli
Guide e consigli


algoritmi e struttura di dati, Appunti di Elementi di Informatica

si tratta del corso di algoritmo

Tipologia: Appunti

2018/2019

Caricato il 08/05/2019

awambo
awambo 🇮🇹

4.5

(4)

7 documenti

1 / 28

Toggle sidebar

Questa pagina non è visibile nell’anteprima

Non perderti parti importanti!

bg1
TEOREMA MAESTRO
Siano a1 e b>1 costanti e f(n) una funzione, e T(n) sia definito sugli interi non negativi dalla ricorrenza:
T(n) = aT(n/b) + f(n) ;
allora T(n) può essere asintoticamente limitato come segue:
1. Se f(n) = O(𝑛𝑙𝑜𝑔𝑏𝑎−𝜀) per qualche costante ε>0, allora T(n) = Ɵ(𝑛𝑙𝑜𝑔𝑏𝑎);
2. Se f(n) = Ɵ(𝑛𝑙𝑜𝑔𝑏𝑎), allora T(n) = Ɵ(𝑛𝑙𝑜𝑔𝑏𝑎 𝑙𝑔 𝑛);
3. Se f(n) = Ω(𝑛𝑙𝑜𝑔𝑏𝑎+𝜀), per qualche costante ε>0, e se a.f(n/b)<c.f(n) per qualche costante c<1 e per
ogni n sufficientemente grande, allora T(n)=Ɵ(f(n)).
INSERTION-SORT
L’algoritmo Insertion-sort risulta efficiente nel caso si debba ordinare un piccolo numero di elementi.
L’insertion-sort funziona nello stesso modo usato da molte persone per ordinare una mano di bridge o
ramino; si inizia con la mano sinistra vuota e le carte coperte poste sul tavolo, quindi si prende dal tavolo
una carta alla volta e la si inserisce nella corretta posizione nella mano sinistra. Per trovare la giusta
posizione per una carta la si confronta con ogni altra carta già nella mano, da destra a sinistra.
Insertion sort prende come parametro un array A[1…n] contenente una sequenza di lunghezza n che deve
essere ordinata. I numeri in input sono ordinati in loco, ossia sono risistemati dentro l’array A con al più un
numero costante di loro memorizzati, ad ogni istante, fuori dall’array.
INSERTION-SORT (A)
1. for j 2 to length[A]
2. do key A[j]
3. # si inserisce A[j] nella sequenza ordinata A[1…j-1]
4. i j 1
5. while i > 0 e A[i] > key
6. do A[i+1] A[i]
7. i i 1
8. A[i+1] key
L’indice j indica la “carta corrente” che deve essere inserita nella mano, gli elementi A[1…j-1] dell’array
costituiscono le carte già ordinate che sono nella mano ed infine, gli elementi A[j+1…n] corrispondono al
mazzetto di carte ancora sul tavolo.
L’indice j si sposta da sinistra a destra su tutto l’array: ad ogni iterazione del ciclo for “più esterno”,
l’elemento A[j] è copiato fuori dall’array (linea 2), quindi cominciando dalla posizione j-1, gli elementi sono
spostati ad uno ad uno di una posizione a destra finché non viene trovata la giusta posizione per A[j] (linne
4-7): a questo punto l’elemento A[j] è inserito (linea 8).
pf3
pf4
pf5
pf8
pf9
pfa
pfd
pfe
pff
pf12
pf13
pf14
pf15
pf16
pf17
pf18
pf19
pf1a
pf1b
pf1c

Anteprima parziale del testo

Scarica algoritmi e struttura di dati e più Appunti in PDF di Elementi di Informatica solo su Docsity!

TEOREMA MAESTRO

Siano a≥1 e b>1 costanti e f(n) una funzione, e T(n) sia definito sugli interi non negativi dalla ricorrenza:

T(n) = aT(n/b) + f(n) ;

allora T(n) può essere asintoticamente limitato come segue:

1. Se f(n) = O(𝑛

𝑙𝑜𝑔

𝑏

𝑎−𝜀

) per qualche costante ε>0, allora T(n) = Ɵ(𝑛

𝑙𝑜𝑔

𝑏

𝑎

2. Se f(n) = Ɵ(𝑛

𝑙𝑜𝑔

𝑏

𝑎

), allora T(n) = Ɵ(𝑛

𝑙𝑜𝑔

𝑏

𝑎

3. Se f(n) = Ω(𝑛

𝑙𝑜𝑔

𝑏

𝑎+𝜀

), per qualche costante ε>0, e se a.f(n/b) 0 e A[i] > key

  1. do A[i+1]  A[i]
  2. i  i – 1
  3. A[i+1]  key

L’indice j indica la “carta corrente” che deve essere inserita nella mano, gli elementi A[1…j-1] dell’array

costituiscono le carte già ordinate che sono nella mano ed infine, gli elementi A[j+1…n] corrispondono al

mazzetto di carte ancora sul tavolo.

L’indice j si sposta da sinistra a destra su tutto l’array: ad ogni iterazione del ciclo for “più esterno”,

l’elemento A[j] è copiato fuori dall’array (linea 2), quindi cominciando dalla posizione j-1, gli elementi sono

spostati ad uno ad uno di una posizione a destra finché non viene trovata la giusta posizione per A[j] (linne

4 - 7): a questo punto l’elemento A[j] è inserito (linea 8).

Complessità di Insertion Sort

Per ciascun j=2,3,…,n, dove n=length[A], sia t j

il numero di volte che il test del ciclo while alla linea 5 è

eseguito per quel valore j.

INSERTION SORT (A) Costo N° di volte

  1. for j  2 to length[A] C 1

n

  1. do key  A[j] C 2

n- 1

  1. si inserisce A[j] nella sequenza ordinata A[1…j-1] 0 n- 1

  2. i  j – 1 C 4

n- 1

  1. while i > 0 e A[i] > key C 5

𝑗

𝑛

𝑗= 2

  1. do A[i+1]  A[i] C 6

𝑗

𝑛

𝑗= 2

  1. i  i – 1 C 7

𝑗

𝑛

𝑗= 2

  1. A[i+1]  key C 8

n- 1

Dunque:

T(n) = c 1

n + c 2

(n-1) + c 4

(n-1) + c 5

𝑗

𝑛

𝑗= 2

  • c 6

𝑗

𝑛

𝑗= 2

      • c 7

𝑗

𝑛

𝑗= 2

      • c 8

(n-1) ;

  • Caso migliore: il caso migliore di insertion sort si ha quando l’array è già ordinato: per ciascun

j=1,2,…,n, si ha che A[i] ≤ key nella linea 5, quando i ha il suo valore iniziale di j - 1.

Quindi t j

= 1 per ogni j= 2,3,…,n, con un tempo di esecuzione pari a:

T(n) = c 1

n + c 2

(n-1) + c 4

(n-1) + c 5

(n-1) + c 8

(n-1) = (c 1

  • c 2

  • c 4

  • c 5

  • c 8

)n – (c 2

  • c 4

  • c 5

  • c 8

Dunque si ha T(n)=Ɵ(n).

  • Caso peggiore: il caso peggiore si verifica quando l’array è ordinato in ordine inverso. Infatti

bisogna confrontare ogni elemento A[j] con ogni elemento nell’intero sottoarray A[1…j-1], così che

t j

= j per j = 2,3,…,n.

Sfruttando che: ∑ 𝑗

𝑛

𝑗= 2

𝑛

( 𝑛+ 1

)

2

− 1 , e che ∑ (𝑗 − 1 )

𝑛

𝑗= 2

𝑛

( 𝑛− 1

)

2

si trova che nel caso peggiore il tempo di esecuzione di insertion sort è:

T(n) = c 1

n + c 2

(n-1) + c 4

(n-1) + c 5

𝑛

( 𝑛+ 1

)

2

− 1 ) + c 6

𝑛

( 𝑛+ 1

)

2

− 1 ) + c 7

𝑛

( 𝑛+ 1

)

2

− 1 ) + c 8

(n-1) =

𝑐 5

2

𝑐 6

2

𝑐 7

2

) n

2

1

2

4

𝑐 5

2

𝑐 6

2

𝑐 7

2

8

) n – (𝑐

2

4

5

8

Dunque si ha T(n) = Ɵ(n

2

  • Caso medio: Il caso medio è in genere altrattanto cattivo quanto il caso peggiore. Si supponga di

aver scelto n numeri a caso a cui applicare l’insertion sort; quanto tempmo si impiega per

determinare dove inserire A[j] nel sottoarray A[1…j-1]? Mediamente metà elementi di A[1…j-1]

sono più piccoli di A[j] e quelli dell’altra metà sono più grandi.

In media perciò si deve verificare solo una metà di A[1…j-1] e quindi t j

= j/2. Calcolando il risultante

tempo di esecuzione nel caso medio, si ottiene una funzione quadratica della dimensione

dell’input, proprio come per il tempo di esecuzione nel caso peggiore.

Dunque T(n) = Ɵ(n

2

MERGE SORT:

L’algoritmo merge sort segue in modo stretto la filosofia del divide-et-impera; intuitivamente esso funziona

nel seguente modo:

  • DIVIDE: divide gli n elementi della sequenza da ordinare in due sottosequenze di n/2 elementi

ciascuna;

  • IMPERA: ordina, usando ricorsivamente (“risalendo”)il merge sort, le due sottosequenze;
  • COMBINA: fonde le due sottosequenze per produrre come risposta la sequenza ordinata.

L’operazione chiave dell’algoritmo di merge sort è la fusione delle due sottosequenze ordinate nel passo

“combina”. Per eseguire una tale fusione usa una procedura ausiliaria MERGE (A,p,q,r), dove A è un array,

p,q ed r sono indici di elementi dell’array tali che p≤q≤r; questa procedura, assumendo che A[p…q] e

A[q+1…r] siano ordinati, genera un singolo sottoarray che sostituisce il corrente sottoarray A[p…r].

Complessità di merge sort

Siccome è un algoritmo che richiama se stesso, il tempo di esecuzione di Merge sort può essere descritto

con una ricorrenza , che descrive il tempo di esecuzione complessivo di un problema di dimensione n in

termini del tempo di esecuzione per input più piccoli.

Si può ipotizzare, senza perdita di generalità, che il problema originale di Merge sort abbia una dimensione

che è una potenza di due. In tal caso, ciascun passo di divisione produce due sottosequenze di dimensione

n/2.

  • Caso peggiore: Si può ragionare nel seguente modo: il merge sort applicato ad un singolo elemento

impiega un tempo costante; quando si hanno n>1 elementi, si suddivide il tempo di esecuzione

come illustrato:

o DIVIDE: il passo di divisione calcola l’indice di mezzo dell’array impiegando, quindi, un

tempo costante, da cui D(n)=Ɵ(n);

o IMPERA: si risolvono ricorsivamente due sottoproblemi, ciascuno di dimensione n/2, che

contribuiscono per 2T(n/2) al tempo di esecuzione;

o COMBINA: si è già esaminato il fatto che la procedura MERGE su sottoarray di n elementi

impiega un tempo di Ɵ(n), per cui C(n)= Ɵ(n).

Addizionando le funzioni D(n) e C(n), si sta sommando una funzione Ɵ(n) ed una funzione Ɵ(1),

ossia si ottiene una funzione lineare Ɵ(n); addizionandola al termine del passo “impera”, si ottiene

la ricorrenza T(n) per il tempo di esecuzione nel caso peggiore del merge sort:

T(n) = {

𝑛

2

) + Ɵ(n) 𝑠𝑒 𝑛 > 1

Applicando il teorema maestro, si ottiene che T(n) = Ɵ(n lgn)

HEAP SORT

L’algoritmo heap sort impiega l’uso di una struttura di dati denominato come “ heap ” (in italiano, cataste),

ossia un array che può essere visto come un albero binario quasi completo, in cui ogni nodo dell’albero

corrisponde ad un elemento dell’array che contiene il valore del nodo. L’albero è riempito completamente

su tutti i livelli tranne, eventualmente, il più basso che è riempito da sinistra in poi. Un array A che

rapresenta uno heap è un oggetto con due attributi: length[A], che è il numero di elementi dell’array, e

heap-size[A], il numero di elementi dello heap memorizzati nell’array A. La radice dell’albero è A[1], e se i è

l’indice di un nodo, l’indice del padre PARENT(i), del figlio sinistro LEFT(i) e del figlio destro RIGHT(i)

possono essere calcolati semplicemente:

PARENT (i)

Return parte_int_inf(i/2)

LEFT (i)

Return 2i

RIGHT (i)

Return 2i + 1

Gli heap soddisfano la proprietà dello heap , cioè per ogni nodo i diverso dalla radice, A[PARENT(i)]≥A[i],

ossia il valore di un nodo è minore o uguale al valore del padre. Quindi l’elemento più grande nello heap è

memorizzato nella radice ed i sottoalberi di qualunque nodo contengono valori non maggiori del valore del

nodo stesso.

L’ altezza di un nodo di un albero è il numero di archi sul più lungo cammino semplice discendente che va

dal nodo ad una foglia, e si definisce altezza dell’albero l’altezza della sua radice. Poiché uno heap di n

elementi si basa su un albero binario completo, osservando che

ℎ+ 1

2

2

𝑛 , segue che h = Ɵ(lg n).

Per mantenere la proprietà dello heap si utilizza la procedura HEAPIFY. I suoi input sono un array A ed un

indice i nell’array. Quando HEAPIFY viene chiamata, si assume che gli alberi binari con radice in LEFT(i) e

RIGHT(i) siano heap, ma che A[i] possa essere più piccolo dei suoi figli, violando così la proprietà dello heap.

La funzione di HEAPIFY è di lasciar “scendere” il valore di A[i] nello heap in modo che il sottoalbero con

radice di indice i diventi uno heap.

HEAPIFY (A,i)

  1. l  LEFT(i)
  2. r  RIGHT(i)
  3. if l ≤ heap-size[A] e A[l]>A[i]
  4. then largest  l
  5. else largest  i
  6. if r ≤ heap-size[A] e A[r]>A[largest]
  7. then largest  r
  8. if largest ≠ i
  9. then scambia A[i] con A[largest]
  10. HEAPIFY(A,largest)

Ad ogni passo, è determinato il più grande tra A[i],A[LEFT(i)] e A[RIGHT(i)] ed il suo indice è memorizzato in

largest. Se A[i] è più grande, allora il sottoalbero con radice nel nodo i è uno heap e la procedura termina.

HEAPSORT (A)

1. BUILDHEAP (A)

  1. for i  length[A] downto 2
  2. do scambia A[1] con A[i]
  3. heap-size[A]  heap-size[A]- 1

5. HEAPIFY(A,1)

Complessità di heapsort:

La procedura HEAPSORT impiega un tempo O=(n lgn), poiché la chiamata a BUILD-HEAP impiega un tempo

O(n) e ognuna delle n-1 chiamate a HEAPIFY impiega un tempo O(lgn).

QUICKSORT

Il quicksort è un algoritmo di ordinamento che spesso risulta la migliore scelta pratica di ordinamento

perché in media è notevolmente efficiente: il suo tempo medio di esecuzione è Ɵ(n lgn), ed i fattori costanti

nasacosti dalla notazione Ɵ(n lgn) sono sufficientemente piccoli. Offre inoltre il vantaggio di ordinare in

loco.

Quicksort utilizza un sottoprogramma usato per il partizionamento chiamato partition , che risistema il

sottoarray A[p…r] in loco.

Il quicksort, come il merge-sort, è basato sulla filosofia divide-et-impera, che richiede le seguenti 3 fasi:

  • DIVIDE: l’array A[p…r] è ripartito in due sottoarray non vuoti A[p…q] e A[q+1…r] in modo tale che

ogni elemento di A[p…q] sia minore o uguale a qualunque elemento di A[pq+1…r]. L’indice q è

calcolato dalla procedura di partizionamento PARTITION;

  • IMPERA: i due sottoarray A[p…q] e A[q+1…r] sono rodinati con chiamate ricorsive a quicksort;
  • RICOMBINA: poiché i sottoarray sono ordinati in loco, non è richiesto alcuno sforzo per

ricombinarli: l’intero array A[p…r] è subito ordinato. La seguente procedura realizza il quicksort:

QUICKSORT (A,p,r)

  1. if p < r
  2. then q  PARTITION (A,p,r)
  3. QUICKSORT (A,p,q)
  4. QUICKSORT(A,q+1,r)

PARTITION (A,p,r)

  1. x  A[p]
  2. i  p- 1
  3. j  r+
  4. while TRUE
  5. do repeat j  j- 1
  6. until A[j] ≤ x
  7. repeat i  i+
  8. until A[i] ≥ x
  9. if i < j
  10. then scambia A[i] con A[j]
  11. else return j

Concettualmente, la procedura di partizione esegue una funzione semplice: pone gli elementi più piccoli di

x nella regione bassa dell’array e quelli più grandi di x nella regione alta.

Il tempo di esecuzione di PARTITION su un array A[p…r] è Ɵ(n), dove n=r-p+1.

Complessità di quicksort

Il tempo di esecuzione di quicksort dipende dal fatto che il partizionamento sia bilanciato o sbilanciato e ciò

a sua volta dipende da quali elementi sono usati per il partizionamento. Se il partizionamento è bilanciato,

l’algoritmo viene eseguito con la stessa velocità asintotica del merge-sort. Se il partizionamento è

sbilanciato tuttavia può essere asintoticamente lento come l’insertion sort.

  • Caso peggiore:

Il comportamento peggiore del quicksort avviene quando il sottoprogramma di partizionamento

produce una regione con n-1 elementi ed una con un solo elemento.

Supponendo che questo partizionamento sbilanciato avvenga ad ogni passo dell’algoritmo, poiché

il partizionamento richiede tempo Ɵ(n) e T(1)= Ɵ(1), la ricorrenza per il tempo di esecuzione è:

T(n)=T(n-1) + Ɵ(n)

Osservando che T(1)= Ɵ(1), si ha:

T(n)=T(n-1) + Ɵ(n) =

k

𝑛

𝑘= 1

k

𝑛

𝑘= 1

) = Ɵ(n

2

dove l’ultima uguaglianza si ottiene osservando che ∑ k

𝑛

𝑘= 1

è la serie aritmetica.

VERSIONE RANDOMIZZATA DI QUICKSORT

Nell’analizzare il comportamento di quicksort nel caso medio, è stata fatta l’ipotesi che tutte le

permutazioni dei numeri in input fossero equivalentemente probabili. Tuttavia quest’ipotesi non vale

sempre.

Si passa dunque alla versione Randomized quicksort , il cui comportamento non dipende solo dall’input, ma

anche da valori prodotti da un generatore di numeri pseudo-casuali. Questa versione di Quicksort ha la

proprietà che nessun input particolare provoca il caso peggiore nel comportamento dell’algoritmo. Il caso

peggiore dipende invece dal generatore di numeri casuali: anche intenzionalmente, non si riesce a produrre

un cattivo array di input, in quanto le permutazioni casuali rendono irrilevante l’ordine dell’input.

L’algoritmo randomizzato si comporta male solo se il generatore di numeri casuali produce una

permutazione sfortunata da ordinare.

Modificando la procedura PARTITION, si può progettare un’altra versione randomizzata del quicksort che

usi la seguente strategia di scelta casuale: ad ogni passo dell’algoritmo quicksort, prima di partizionare

l’array, si scambia l’elemento A[p] con un elemento a caso in A[p…r]. Questa modifica assicura che

l’elemento perno x=A[p] sia in modo equiprobabile uno qualunque degli r-p+1 elementi del sottoarray. Di

conseguenza ci si aspetta che la suddivisione dell’array in input sia, in media, ragionevolmente ben

bilanciata. La nuova procedura di partizionamento è:

RANDOMIZED-PARTITION (A,p,r)

  1. i  RANDOM (p,r)
  2. scambia A[p] ↔ A[i]
  3. return PARTITION (A,p,q)

Il nuovo quicksort si chiama adesso RANDOMIZED-QUICKSORT:

RANDOMIZED-QUICKSORT (A,p,r)

  1. if p < r
  2. then q  RANDOMIZED-PARTITION (A,p,r)
  3. RANDOMIZED-QUICKSORT (A,p,r)
  4. RANDOMIZED-QUICKSORT(A,q+1,r)

COUNTING-SORT

Il counting-sort si basa sull’ipotesi che ognuno degli n elementi di input sia un intero nell’intervallo da 1 a k,

per un certo intero k. Quando k=O(n), l’ordinamento viene eseguito in tempo O(n).

L’idea di base di counting-sort è di determinare, per ogni elemento x in input, il numero di elementi minori

di x. Questa informazione può essere usata per porre x direttamente nella sua posizione nell’array di

output. Per esempio, se vi sono 17 elementi minori di x, allora x v messo in posizione 18 dell’output.

Nel codice di counting-sort si assume che l’input sia un array A[1…n] e che length[A]=n.

Inoltre, sono richiesti altri due array: l’array B[1…n] mantiene l’output ordinato e l’array C[1…k] fornisce la

memoria di lavoro temporanea.

COUNTING-SORT (A,B,k)

  1. for i  1 to k
  2. do C[i]  0
  3. for j  2 to length[A]
  4. do C[A[j]]  C[A[j]] + 1
  5. C[i] contiene ora il numero di elementi uguali ad i.

  6. for i  2 to k
  7. do C[i]  C[i] + C[i-1]
  8. C[i] contiene ora il numero di elementi minori o uguali ad i.

  9. for j  lenght[A] downto 1
  10. do B[C[A[j]]]  A[j]
  11. C[A[j]]  C[A[j]] – 1

Complessità di counting-sort

Il ciclo for nelle linee 1-2 impiega un tempo O(k), il ciclo for nelle linee 3-4 impiega un tempo O(n), il ciclo

for nelle linee 6 - 7 impiega un tempo O(k) ed il ciclo for nelle linee 9 - 11 impiega un tempo O(n). Dunque il

tempo totale è O(n+k); tuttavia nelle ipotesi di counting-sort si era supposto che k=O(n), da cui la

complessità complessiva è O(n).

(elementi) ed n contenitori (bucket) ed ogni pallina è lanciata indipendentemente con probabilità p=1/n di

cadere in qualunque bucket. Così la probabilità che n i

= k segue che la distribuzione binomiale b(k;n,p), che

ha valore medio E[n i

] = np = 1 e varianza Var[n i

]=np(1-p)=1-1/n. Per qualunque variabile casuale X,

l’equazione precedente fornisce:

E[n

i

2

] = Var[n i

] + E

2

[n i

] = 1 - 1/n + 1

2

= 2- 1/n = Ɵ(1).

Utilizzando questo limite, si conclude che il tempo atteso nel caso medio per l’ordinamento con l’insertion

sort è O(n).

GRAFI

Un grafo orientato G è una coppia (V,E) dove V è l’ insieme dei vertici mentre E è l’ insieme degli archi.

Un grafo non orientato G è una coppia (V,E) dove V è l’ insieme dei vertici ed E è costituito da coppie di

vertici non ordinate : l’arco (u,v) e l’arco (v,u) sono considerati lo stesso arco.

  • Se (u,v) è un arco di un grafo G, si dice che il vertice v è adiacente al vertice u.
  • Se (u,v) è un arco di un grafo orientato G, si dice che (u,v) è incidente il vertice u (o che esce dal

vertice u) ed è incidente il vertice v (o che entra nel vertice v);

  • Se (u,v) è un arco di un grafo non orientato, si dice che il vertice v è adiacente al vertice u.
  • In un grafo orientato, un arco del tipo (v,v) si dice cappio ;
  • In un grafo non orientato, il grado di un vertice è il numero di archi incidenti su di esso;
  • In un grafo orientato, il grado uscente di un vertice è il numero di archi che escono da esso, mentre

il grado entrante è il numero di archi che entrano in esso. Il grado di un vertice è la somma dei

gradi entranti ed uscenti;

  • Un vertice di grado 0 si dice vertice isolato.

Un cammino di lunghezza k da un vertice u ad un vertice u’ di un grafo G è una sequenza di

vertici tale che v 0 =u, vk=u’, e l’arco (vi- 1 ,vi) appartiene ad E per ogni i=1,2,…,k. La lunghezza di un cammino è

il numero di archi di un cammino. Si dice che il cammino contiene i vertici v 0

,v 1

,v 2

,…,v k

e gli archi

(v 0

,v 1

),(v 1

,v 2

),…,(v k- 1

,v k

). Se esiste un cammino p da un vertice u ad un vertice u’, si dice che u’ è

raggiungibile da u tramite il cammino p; si denoterà con u u’.

Un cammino è semplice se tutti i vertici contenuti sono distinti.

Un sottocammino di un cammino p= è una sottosequenza contigua dei suoi vertici <

v i

,v i+

,…,v j

> per ogni 1≤i≤j≤k.

  • In un grafo orientato, un cammino< v 0

,v 1

,v 2

,…,v k

> forma un ciclo se v 0

=v k

ed il cammino contiene

almeno un arco.

Un ciclo è semplice se v 1

,v 2

,…,v k

sono tutti distinti. Un cappio è un ciclo di lunghezza 1.

Un grafo orientato senza cappi si dice grafo semplice.

  • In un grafo non orientato, un cammino < v 0

,v 1

,v 2

,…,v k

> forma un ciclo (semplice) se k≥3, v 0

= v k

e

v 1

,v 2

,…,v k

sono tutti distinti.

Un grafo senza cicli si dice grafo aciclico.

  • Un grafo non orientato è connesso se ogni coppia di vertici è collegata con un cammino.

RAPPRESENTAZIONE DI GRAFI

Vi sono due modi standard di rappresentare un grafo G=(V,E): come una collezione di liste di adiacenza o

come una matrice di adiacenza. Di solito si preferisce la rappresentazione con liste di adiacenza perché

essa fornisce un modo compatto di rappresentare grafi sparsi , ossia quelli per cui |E| è molto minore di

|V|

2

. Tuttavia una rappresentazione con matrice di adiacenza può essere preferita quando il grafo è denso ,

ossia quando |E| è vicino a |V|

2

, oppure quando occorre essere in grado di dire rapidamente se vi è un

arco che collega due vertici dati.

La rappresentazione con liste di adiacenza di un grafo G=(V,E) consiste in un vettore Adj di |V| liste, una

per ogni vertice in V. Per ogni vertice u, la lista di adiacenza Adj[u] contiene (puntatori a) tutti i vertici v tali

che esista un arco (u,v) appartenente ad E; quindi Adj[u] comprende tutti i vertici adiacenti ad u in G. In

ogni lista di adiacenza i vertici vengono di solito memorizzati in un ordine arbitrario.

Se G è un grafo orientato, la somma delle lunghezze di tutte le liste di adiacenza è |E|, perché un arco della

forma (u,v) è rappresentato ponendo v in Adj[u]. Se G è un grafo non orientato, la somma delle lunghezze

di tutte le liste di adiacenza è 2|E|, perché se (u,v) è un arco non orientato, allora u appare nella lista di

adiacenza di v e viceversa. Sia per un grafo orientato sia per uno non orientato la rappresentazione con liste

di adiacenza gode della desiderabile proprietà che la quantità di memoria necessaria è O(max(V,E)) =

O(V+E).

Le liste di adiacenza possono essere facilmente adattate per rappresentare grafi pesati , cioè grafi per i quali

ogni arco ha associato un peso normalmente dato da una funzione peso w:E-> R. Ad esempio, sia G=(V,E)

un grafo pesato con una funzione peso w. Il peso w(u,v) dell’arco (u,v) viene memorizzato semplicemente

insieme al vertice v nella lista di adiacenza di u. La rappresentazione con liste di adiacenza è abbastanza

robusta, nel senso che può essere adattata a molte altre varianti di grafi.

Uno svantaggio potenziale della rappresentazione con liste di adiacenza è che per determinare se un dato

arco (u,v) è presente nel grafo non vi è metodo più veloce che cercare v nella lista di adiacenza Adj[u]. Si

può porre rimedio a questo svantaggio con una rappresentazione del grafo come matrice di adiacenza, al

costo di usare asintoticamente più memoria.

Per la rappresentazione con matrice di adiacenza di un grafo G=(V,E), si assume che i vertici siano numerati

1,2,…,|V| in modo arbitrario. La rappresentazione con matrice di adiacenza di un grafo G consiste in una

matrice A=(a ij

) di dimensione |V|x|V| tale che:

a ij

Come per la rappresentazione con liste di adiacenza, anche la rappresentazione con matrice di adiacenza

può essere usata per grafi pesati. Ad esempio, se G=(V,E) è un grafo pesato con funzione peso w, il peso

w(u,v) dell’arco (u,v)ϵE viene memorizzato semplicemente come l’elemento in riga u e colonna v della

matrice di adiacenza. Se un arco non esiste si può memorizzare un valore NIL nella corrispondente

posizione della matrice, anche se per molti problemi può essere conveniente usare in valore come 0 oppure

Sebbene la rappresentazione con liste di adiacenza sia asintoticamente almeno tanto efficiente quanto la

rappresentazione con matrici di adiacenza, la semplicità di una matrice di adiacenza può renderla

preferibile quando i grafi siano ragionevolmente piccoli. Inoltre, se il grafo non è pesato, vi è un ulteriore

vantaggio per la rappresentazione con matrice di adiacenza riguardante la memorizzazione: infatti, invece

di usare una intera parola di memoria per ogni elemento della matrice di adiacenza, si può, usare un

singolo bit.

BFS (G,s)

  1. for ogni vertice u ϵ V[G]-{s}
  2. do color[u]  WHITE
  3. d[u]  ∞
  4. π[u]  NIL
  5. color[s]  GRAY
  6. d[s]  0
  7. π[s]  NIL
  8. Q  {s}
  9. while Q ≠ Ø
  10. do u  head[Q]
  11. for ogni v ϵ Adj[u]
  12. do if color[v] = WHITE
  13. then color[v]  GRAY
  14. d[v]  d[v] + 1
  15. π[v]  u
  16. ENQUEUE(Q,v)

17. DEQUEUE(Q)

  1. color[u]  BLACK

La procedura di visita in ampiezza che precede, chiamata BFS, assume che il grafo in input G=(V,E) sia

rappresentato usando liste di adiacenza. Essa mantiene diverse strutture di dati addizionali associate ad

ogni vertice del grafo: ad esempio, il colore di ogni vertice uϵV è memorizzato nella variabile color[u],

mentre il predecessore di u viene ricordato nella variabile π[u]. Se u non ha predecessore (ad esempio se

u=s o se u non è stato scoperto), allora π[u]=NIL. La distanza dalla sorgente s al vertice u calcolata

dall’algoritmo viene memorizzata in d[u].

La procedura BFS funziona nel modo seguente. Le linee 1-4 colorano tutti i vertici di bianco, assegnano

infinito alla variabile d[u] per ogni vertice u ed inizializzano il predecessore di ogni vertice con NIL. La linea 5

colora il vertice sorgente s di grigio, poiché si assume che esso venga scoperto non appena parte la

procedura; la linea 6 inizializza d[s] con 0 e la linea 7 pone NIL come predecessore della sorgente. La linea 8

inizializza Q con la coda che contiene il solo vertice s; da questo momento in poi Q conterrà sempre

l’insieme dei vertici grigi.

Il ciclo principale del programma è contenuto nelle linee 9-18. Il ciclo viene ripetuto finché esistono dei

vertici grigi, cioè dei vertici già scoperti le cui liste di adiacenza non siano ancora state completamente

esaminate. La linea 10 determina il vertice grigio u che si trova in testa alla coda Q. Il ciclo for nelle linee 11-

16 esamina ogni vertice v nella lista di adiacenza di u. Se v è bianco (e quindi non è stato ancora scoperto)

l’algoritmo lo “scopre” eseguendo le linee 13-16: dapprima esso viene colorato di grigio e la sua distanza

d[v] viene posta a d[u]+1; quindi u viene memorizzato come suo predecessore; infine v viene posto in fondo

alla coda Q. Quando tutti i vertici della lista di adiacenza di u sono stati esaminati, nelle linee 17-18 u viene

rimosso da Q e viene colorato di nero.

Complessità di BFS

Sia G=(V,E) il grafo in input. Dopo l’inizializzazione nessun vertice verrà mai più colorato di bianco, e quindi

il test della linea 12 assicura che ogni vertice venga inserito nella coda al massimo una volta, e di

conseguenza che venga eliminato dalla coda al massimo una volta. Le operazioni di inserimento e di

eliminazione dalla coda richiedono tempo O(1), quindi il tempo totale dedicato alle operazioni sulla coda è

O(V). Poiché la lista di adiacenza di ogni vertice viene scandita solo quando il vertice è estratto dalla coda, la

lista di adiacenza di ogni vertice viene scandita al massimo una volta; inoltre, poiché la somma delle

lunghezze di tutte le liste di adiacenza è Ɵ(E). Infine il tempo necessario per l’inizializzazione è O(V), e

quindi il tempo totale di esecuzione della procedura BFS è O(V+E): di conseguenza la visita in ampiezza

richiede un tempo lineare nella dimensione della rappresentazione con liste di adiacenza di G.

Cammini minimi

Si definisce la distanza sul cammino minimo δ(s,v) da s a v come il minimo numero di archi di un cammino

dal vertice s al vertice v, oppure ∞ se non esiste nessun cammino da s e v. Un cammino di lunghezza δ(s,v)

da s a v è chiamato un cammino minimo da s a v.

Lemma

Sia G=(V,E) un grafo orientato o non orientato, e sia s ϵ V un vertice arbitrario. Allora per ogni arco (u,v) ϵ E

si ha: δ(s,v) ≤ δ(s,u) + 1.

Lemma

Sia G=(V,E) un grafo orientato o non orientato, e si supponga che la procedura BFS venga eseguita su G a

partire da un dato vertice sorgente s ϵ V. Allora, al termine della procedura, per ogni vertice v ϵ V il valore

d[v] calcolato da BFS soddisfa d[v] ≥ δ(s,v).

La procedura DFS funziona nela seguente modo. Le linee 1-3 colorano tutti i vertici di bianco ed inizializzano

i loro campi π a NIL; la linea 4 azzera il contatore globale del tempo. Le linee 5-7 controllano tutti i vertici di

V, e quando ne trovano uno bianco lo visitano usando la procedura DFS-VISIT. Ogni volta che DFS-VISIT(u)

viene invocata nella linea 7, il vertice u diventa radice di un nuovo albero della foresta DFS. Quando DFS

termina, ad ogni vertice u è stato assegnato un tempo di scoperta d[u] ed un tempo di fine visita f[u].

In ogni chiamata DFS-VISIT(u), il vertice u è inizialmente bianco. La linea 1 colora u di grigio e la linea 2

memorizza il tempo di scoperta d[u] incrementando la variabile globale time e memorizzandone il valore.

Le linee 3-6 esaminano ogni vertice v adiacente ad u e visitano ricorsivamente v se esso è bianco. Non

appena un vertice v ϵ Adj[u] viene considerato nella linea 3, diciamo che l’arco (u,v) è stato esplorato dalla

visita in profondità. Infine, dopo che ogni arco uscente da u è stato esplorato, le linee 7-8 colorano u di

nero e registrano il tempo di fine visita f[u].

Complessità di DFS

I due cicli che si trovano rispettivamente nelle linee 1-3 e 5-7 di DFS richiedono tempo Ɵ(V), escluso il

tempo necessario per eseguire le chiamate a DFS-VISIT. La procedura DFS-VISIT è chiamata esattamente

una volta per ogni vertice v ϵ V, poiché DFS-VISIT viene invocata solo su vertici bianchi e la prima cosa che

fa è di colorare il vertice di grigio. Durante una esecuzione di DFS-VISIT(v) il ciclo nelle linee 3-6 viene

eseguito |Adj[v]| volte. Poiché

∑ |Adj[v]|

v ϵ V

= Ɵ(E),

il costo totale dell’esecuzione delle linee 3-6 di DFS-VISIT è Ɵ(E). Quindi il tempo di esecuzione di DFS è

Ɵ(V+E).

Proprietà della visita in profondità

La visita in profondità fornisce molte informazioni sulla struttura di un grafo. Forse la più importante della

visita in profondità è che il sottografo dei predecessori G π

forma effettivamente una foresta di alberi,

poiché la struttura degli alberi DFS rispecchia fedelmente la struttura delle chiamate ricorsive DFS-VISIT. Più

precisamente, si ha che u= π[v] se e solo se DFS-VISIT(v) è stata chiamata durante la visita della lista di

adiacenza di u.

Un’altra importante proprietà della visita in profondità è che i tempi di scoperta e di fine visita hanno una

struttura di parentesi , nel senso che se si rappresentano la scoperta di un vertice u con una parentesi

sinistra “(u” e la fine della sua visita con un parentesi destra “u)”, allora la storia degli eventi di scoperta e di

fine visita di tutti i vertici costituisce una espressione ben formata, nel senso che le parentesi sono

correttamente bilanciate. Il teorema che segue enuncia in modo più formale questa proprietà:

Teorema (delle parentesi)

In ogni visita in profondità di un grafo G=(V,E) (orientato o non orientato), per ogni coppia di vertici w e v,

una ed una sola delle seguenti condizioni è soddisfatta:

  • gli intervalli [d[u],f[u]] e [d[v],f[v]] sono completamente disgiunti;
  • l’intervallo [d[u], f[u]] è contenuto interamente nell’intervallo [d[v],f[v]] e u è un discendente di v

nell’albero DFS;

  • l’intervallo [d[v], f[v]] è contenuto intermente nell’intervallo [d[u],f[u]] e v è un discendente di u

nell’albero DFS.