Funzioni efficienti (grafi) - 12/09/2016

Si stabilisca quale problema risolve il seguente algoritmo, che accetta in ingresso un grafo non orientato G = (V, E):

MyAlgorithm(G)
1. A = ∅
2. ordina i vertici di G per grado crescente
3. for each u ∈ V[G] do /* vertici estratti secondo l’ordine stabilito nel passo 2 */
4.    if MyFunction(G,A,u) then
5.       A = A ∪ {u}
6. return A
MyFunction(G,A,u)
1. for each v ∈ A do
2.    if (u,v) ∈ E[G] then
3.       return FALSE
4. return TRUE

Si dimostri la correttezza dell’algoritmo, si determini la sua complessità e si simuli infine la sua esecuzione sul seguente grafo:

Svolgimento

  1. l'algoritmo restituisce l'insieme con i gradi più bassi possibile di elementi non collegati tra loro
  2. l'algoritmo è corretto, perché, essendo composto da due cicli for innestati, termina sicuramente
  3. la complessità è data da
    1. ordinamento O(n log(n))
    2. un ciclo Θ(n), che richiama un altro ciclo Θ(n), quindi il costo complessivo è Θ(n2)
    3. la ricerca dell'arco, che a seconda dell'implementazione può essere O(n) o O(log(n))
    4. la complessità totale è quindi O(n log(n)) + Θ(n2) + O(n) ⇒ O(n2)
  4. L'esecuzione:
    1. Si considera l'elemento 6: A è vuoto, quindi MyFunction restituisce true e l'elemento viene aggiunto ad A
    2. Si considera l'elemento 1: A contiene solo 6, che non è collegato a 1. L'elemento è quindi aggiunto.
    3. Si considera l'elemento 5: non è collegato ad alcun vertice in A, quindi l'elemento è aggiunto.
    4. Si considera l'elemento 2: è collegato a 1, che è presente in A, quindi l'elemento non è aggiunto.
    5. Si considera l'elemento 4: è collegato a 5 e 6, che sono presente in A, quindi l'elemento non è aggiunto.
    6. Si considera l'elemento 3: è collegato a 1 e a 5, che sono presente in A, quindi l'elemento non è aggiunto.
    7. Il risultato finale è che A contiene 1, 5, 6.

Funzioni efficienti (grafi) 27/05/2017

Il seguente algoritmo accetta in ingresso la matrice di adiacenza di un grafo non orientato e restituisce un valore Booleano (TRUE / FALSE)

MyAlgorithm( A )
1. n = rows(A) /* determina il numero di vertici del grafo */
2. let B an n x n matrix /* alloca memoria per una matrice B n x n */
3. for i = 1 to n
4.    for j = 1 to n
5.       b[i,j] = 0
6.       for k = 1 to n
7.          b[i,j] = b[i,j] + a[i,k]*a[k,j]
8. for i = 1 to n
10.   for j = i + 1 to n
11.      if a[i,j]*b[i,j] <> 0 then
12.          return FALSE
13.return TRUE

Si calcoli la complessità computazionale di MyAlgorithm e si determini la sua funzione (in quali casi restituisce TRUE? in quali casi FALSE?). (Nota: Nel determinare la complessità si ignori per comodità la complessità delle istruzioni 1 e 2).
Si simuli inoltre il suo comportamento sui seguenti due grafi, verificando che restituisca il risultato atteso:

Risposta

L'algoritmo è trattato nella lezione 10 di Pelillo, in cui si parla del risultato di un prodotto tra la matrice di adiacenza e se stessa, e dei successivi prodotti tra la matrice e il risultato precedente.

Una volta terminato la prima serie di cicli annidati, di costo O(n3), si verifica con un'altra serie di cicli annidati, di costo O(n2), se esistono archi in comune. Se esistono (confronto alla riga 11), il grafo ha almeno un ciclo di lunghezza 3 vertici.
Il costo complessivo è O(n3) + O(n2), quindi O(n3).

Il comportamento si basa su matrice di adiacenza e matrice di appoggio.

Le matrici di adiacenza sono:

1 2 3 4
1 0 1 0 0
2 1 0 1 0
3 0 1 0 1
4 0 0 1 0

e  per il secondo grafo

1 2 3 4
1 0 1 1 0
2 1 0 1 0
3 1 1 0 1
4 0 0 1 0

Una volta terminati le 64 esecuzioni (4 x 4 x 4) della riga 7, si ottengono le seguenti matrici:

1 2 3 4
1 1 0 1 0
2 0 2 0 1
3 1 0 2 0
4 0 1 0 1

Questa matrice è "complementare" a quella del primo grafo, che infatti non presenta cicli.

1 2 3 4
1 1 1 1 1
2 1 2 1 1
3 1 1 2 0
4 1 1 0 1

Questa seconda matrice ha in comune con la seconda matrice di adiacenza i tre archi ((1, 2), (2, 3), (3, 1)) che costituiscono il ciclo.

Costi di esecuzione (strutture dati) 27/05/2015

Completare la seguente tabella indicando la complessità delle operazioni che si riferiscono a un dizionario di n elementi. Per l’operazione Successore si assuma di essere sull’elemento x a cui si applica l’operazione.

Ricerca Massimo Successore Minimo Costruzione
Albero binario di ricerca O(h) O(h) O(h) O(h) O(n)
Min-Heap O(log n) O(log n) O(log n) O(log n) O(n)

Nota: in un albero binario bilanciato, h è l'altezza dell'albero, che è uguale a log(n) per n compreso tra n^(h-1)+1 e n^h

I cinque algoritmi sui grafi da sapere

Kruskal, Prim, Dijkstra, Bellman-Ford, Floyd-Warshall

Kruskal

L'amico Kruskal vuole trovare l'albero (o gli alberi) che collega i vertici tramite gli archi più leggeri possibile.

Per fare questo, divide i vertici di un grafo in tanti sottoinsiemi quanti sono i vertici, poi ordina gli archi e considera le coppie di vertici partendo dall'arco più leggero: se i vertici appartengono allo stesso insieme, ignora l'arco; altrimenti aggiunge l'arco all'albero di copertura e unisce i due sottoinsiemi.

Il risultato è un albero di copertura minimo, il tempo di esecuzione è Θ(E log E).

_(, )
1.  ← ∅
2.  ℎ  ∈ []  //per [] intendiamo l’insieme dei vertici di 
3.   _() //abbiamo creato  insiemi, ognuno dei quali contiene un vertice
4.   ℎ        // tipico degli algoritmi Greedy
5.  ℎ (, ) ∈ []  //che sono ordinati
6.    _() ≠ _() ℎ
7.      ←  ∪ {(, )}
8.     (, )
9.  

Prim

L'amico Prim vuole trovare l'albero (o gli alberi) che collega i vertici tramite gli archi più leggeri possibile.

Per fare questo, sceglie uno dei vertici e lo inserisce nell'albero, poi controlla quale tra gli archi adiacenti è il più leggero (o i più leggeri), aggiunge il vertice all'albero e gli attribuisce un peso pari all'arco. Se durante la visita un vertice è già stato visitato, ma ha un peso maggiore dell'arco corrente, si cambia l'arco connesso e si aggiorna il peso del vertice.

Il risultato è un albero di copertura minimo, il tempo di esecuzione è O(E V log V) a patto di utilizzare per l'archiviazione dell'elenco dei vertici uno heap di Fibonacci.

_(, , )
1.  ← [] //inizializziamo Q con tutti i vertici di G
2.  ℎ  ∈  
3.   [] = ∞
4. [] = 0
5. [] = 
6. ℎ  ≠ ∅ 
7.    ← _() //estrae il vertice con campo key minore
8.    ℎ  ∈ []  //per ogni vertice adiacente al vertice estratto
9.       ∈  && (, ) < [] ℎ
10.       [] ←  //u diventa il padre di v
11.       [] ← (, ) //aggiorna il valore di key e riorganizza l’heap
12.   = {(, []) |  ∈  \ {}}

Dijkstra

L'amico Dijkstra vuole raggiungere nel tempo/spazio minore un vertice partendo da un altro vertice.

Per fare questo, come l'amico Dijkstra, inizializza tutti i vertici con peso ∞ e parent = NIL, poi iniziando dal vertice sorgente imposta nei vertici adiacenti il peso degli archi. Se arriva a un vertice già esplorato, ma il peso del vertice è maggiore del vertice "nuovo padre" più l'arco che li congiunge, ne cambia il padre e aggiorna il peso del vertice (rilassamento).

Il risultato è un albero in cui ciascun vertice è collegato alla sorgente con il percorso più breve possibile. Il costo dipende dall'implementazione dell'elenco dei vertici, e segue questa tabella:

Grafo sparso Grafo denso
Array lineare O(n2) O(n2)
Heap binario O(n log n) O(n2 log n)

dove per "Grafo sparso" si intende un grafo in cui il numero di vertici e il numero di archi tende ad essere uguale E = Θ(V), mentre per "Grafo denso" si intende che E = Θ(V2).

NOTA: Dijkstra si incasina se ci sono archi con peso negativo.

__(, )
1.  ℎ  ∈ []
2.   [] ← +∞
3.   [] ← 
4. [] ← 0

(, , )
1.  [] > [] + (, ) ℎ
2.   [] ← [] + (, ) //aggiorna il peso del cammino da  a 
3.   [] ←  //aggiorna il predecessore

(, , )
1. __(, )
2.  ← ∅ //insieme dei vertici già estratti
3.  ← [] //vertici da estrarre
4. ℎ  ≠ ∅ 
5.    ← _()
6.    ←  ∪ {} // S si riempie
7.    ℎ  ∈ [] 
8.     (, , (, ))

Esempio

Grafo di test con sorgente = 1
  1. V(G) = {1};
    1. (1,2), π(2) = NIL → 1, d(2) = ∞ → 2;
    2. (1,3), π(3) = NIL → 1, d(3) = ∞ → 4
  2. V(G) = {1, 2, 3};
    1. i nodi esplorati sono:
      1. π(1) = NIL, d(1) = 0
      2. π(2) = 1, d(2) = 2
      3. π(3) = 1, d(3) = 4
    2. (2, 4), π(4) = NIL → 2, d(4) = ∞ → d(2) + 7 = 9
    3. (2, 3), π(3) = 1 → 2, d(3) = 4 → d(2) + 1 = 3
    4. (3, 5), π(5) = NIL → 3, d(5) = ∞ → d(3) + 3 = 6
  3. V(G) = {1, 2, 3, 4, 5}
    1. i nodi esplorati sono:
      1. π(1) = NIL, d(1) = 0
      2. π(2) = 1, d(2) = 2
      3. π(3) = 2, d(3) = 3
      4. π(4) = 2, d(4) = 9
      5. π(5) = 3, d(5) = 6
    2. (5, 4), π(4) = 2 → 5, d(5) = 9 → d(5) + 2 = 8
    3. (5, 6), π(6) = NIL → 5, d(6) = ∞ → d(5) + 5 = 11
    4. (4, 6), π(6) = 5 → 4, d(6) = ∞ → d(4) + 1 = 10

Bellman-Ford

Gli amici Bellman e Ford vogliono raggiungere nel tempo/spazio minore un vertice partendo da un altro vertice, ma in un grafo che può avere pesi negativi.

Uno dei motivi per cui i grafi con pesi negativi sono più incasinati di quelli con pesi solo positivi è che se c'è un ciclo e il suo bilancio è negativo (es. a→b = +2, b→a = -5) per ridurre il peso del cammino basta ciclare all'infinito il loop.

L'algoritmo è piuttosto semplice: per ogni vertice per ogni arco applica il rilassamento; poi controlla che per ogni arco il suo peso sia minore o uguale della somma del vertice più l'arco che li congiunge. In caso contrario è di fronte a un cappio con bilancio negativo e l'algoritmo si ferma restituendo falso.

Il costo dipende dal tipo di grafo, se sparso o denso:

Grafo sparso Grafo denso
Dijkstra - Array lineare O(n2) O(n2)
Dijkstra - Heap binario O(n log n) O(n2 log n)
Bellman-Ford O(n2) O(n3)
_(, , )
1. __(, )
2.   = 1  |[]| − 1 
3.    ℎ (, ) ∈ [] 
4.     (, , (, ))
5.  ℎ (, ) ∈ [] 
6.    [] > [] + (, ) ℎ
7.      
8.  

Floyd-Warshall

Gli amici Floyd e Warshall vogliono raggiungere nel tempo/spazio minore qualunque vertice partendo da un altro vertice qualunque, cioè vogliono calcolare tutti i percorsi minimi possibili.

In modo simile a una matrice M di adiacenza, creano una matrice n*n tale con w: E→R che per ogni cella i,j:

  • se i == j allora M[i, j] = 0
  • se i != j e (i, j) è un arco, allora M[i, j] = w(i, j)
  • se i != j e (i, j) non è un arco, allora M[i, j] = ∞

Per ogni vertice controllano se la somma di ciascun vertice entrante più ciascun vertice uscente ha un peso minore dell'arco diretto o del valore calcolato precedentemente.

Il costo spaziale di questo algoritmo è n2, perché abbiamo bisogno di iniziare con quella che - di fatto - è una matrice nxn di adiacenza. Il costo temporale dell'algoritmo è n3, perché per ogni arco (compresi quelli con valore ∞, cioè compresi gli archi "assenti", che quindi corrispondono a ogni coppia nxn) dobbiamo iterare ogni vertice.

Esempio

La matrice di partenza richiede che siano valorizzati tutti gli archi e che gli "auto-archi" siano impostati a zero.

A
DA 1 2 3 4
1 0
2 0
3 0
4 0

Gli archi da nodo verso se stesso sono impostati a zero

1 2 3 4
1 0 3 7
2 8 0 2
3 5 0 1
4 2 0

Gli altri archi sono riportati (senza calcoli). Gli archi rimanenti, che non esistono, sono impostati a infinito.

Passaggio vertice 1: si riportano gli zeri, tutta la riga 1 e tutta la colonna 1, poiché rappresentano archi diretti che non è neccessario calcolare.

Il passaggio "vertice 1"
1 2 3 4
1 0 3 7
2 8 0
3 5 0
4 2 0

Ora cerchiamo di calcolare il cammino da 2 a 3. L'arco diretto riporta il peso 2, dobbiamo capire se arrivare da 2 a 3 passando per 1 è più conveniente. (2,1) pesa 8, (1, 3) pesa infinito, 8+∞ = ∞, quindi ci conviene tenere l'arco diretto.

Il passaggio "vertice 1"
1 2 3 4
1 0 3 7
2 8 0 2
3 5 0
4 2 0

Ora cerchiamo di calcolare il cammino da 2 a 4. L'arco diretto riporta il peso ∞, dobbiamo capire se arrivare da 2 a 4 passando per 1 è più conveniente. (2,1) pesa 8, (1, 4) pesa 7, 8+7 = 15, quindi ci conviene passare per 1.

Il passaggio "vertice 1"
1 2 3 4
1 0 3 7
2 8 0 2 15
3 5 0
4 2 0

Da 3 a 2 l'arco diretto è ∞, mentre passando per 1 è 5+3 = 8, quindi cambiamo in 8.

Da 3 a 4 l'arco diretto è 1, mentre passando per 1 è 5+7 = 12, quindi teniamo 1.

Da 4 a 2 l'arco diretto è ∞, mentre passando per 1 è 2+3 = 5, quindi cambiamo in 5.

Da 4 a 3 l'arco diretto è ∞, mentre passando per 1 è 2+∞ = ∞, quindi lasciamo ∞.

Il passaggio "vertice 1"
1 2 3 4
1 0 3 7
2 8 0 2 15
3 5 8 0 1
4 2 5 0

Partendo da questa tabella, si ripete per gli altri tre vertici.

_()
1.  ← [] //calcolo il numero di righe, ovvero il numero di vertici
2. 0 = 
3.   = 1  
4.     = 1   
5.       = 1   
6.       [,]() = min([,](−1) + [,](−1), [](−1))
7.  

Alberi di copertura

Definizioni

Taglio

Un taglio è una suddivisione dell'insieme dei vertici V in due sottoinsiemi tali che V1∈G1 e V2∈G2, inoltre V1∩V2 = ∅ e V1∪V2 = V.

Attraversamento

Un arco E attraversa un taglio se E(u, v) con u∈G1 e v∈G2 cioè se i due vertici appartengono ciascuno a uno dei due insiemi determinati dal taglio.

Rispetto

Dato un sottoinsieme di vertici A ⊆ G si dice che un taglio rispetta l'insieme se nessuno dei suoi archi lo attraversa.

Arco leggero

Si dice leggero l'arco (o gli archi) che, dato un insieme di archi con una proprietà, ha il valore minimo per quella proprietà. Tipicamente si usa chiamare arco leggero quello con valore più basso tra quelli che attraversano un taglio.

Arco sicuro

Dato un grafo G con un taglio (S, V-S), un insieme A che sia rispettato dal taglio, un arco leggero che attraversa (S, V-S) è detto sicuro.

MST

Sta per Minimum Spanning Tree, o albero di copertura minimo. Rappresenta l'albero i cui archi sono quelli meno pesanti possibile.

Teorema fondamentale dei MTS

SIa G = (V, E) un grafo non orientato e connesso e sia w: E → R. Siano inoltre date le seguenti ipotesi:

  • A è un insieme di archi tale che A ⊆ E e che A ⊆ MST (che chiameremo T)
  • sia (S, V\S) un taglio che rispetta A, ovvero il taglio non attraversa alcun arco di A o viceversa;
  • sia (u,v) ∈ E, tale che sia leggero e che attraversa il taglio (S, V\S), ovvero che sia quello di peso minimo tra gli archi che attraversano il taglio

Allora l'arco (u,v) è sicuro per A.

Kruskal

L'algoritmo di Kruskal crea tanti alberi quanti sono i vertici, poi li unisce se non fanno parte della stessa foresta. L'unione avviene ordinando gli archi per peso dal più piccolo, così quando (se) avviene l'unione si memorizza l'arco sicuro di volta in volta.

  1. A = ∅; inizializza un insieme vuoto
  2. for each v in V, MAKE_SET(v); per ogni vertice nell'insieme dei vertici crea un albero (qui chiamato set)
  3. for E order by w; ordina gli elementi di E per peso (w = weight)
  4. for each e(u, v) in E
    1. if FIND_SET(u) ≠ FIND_SET(v); se non appartengono allo stesso albero
      1. A ← A ∪ {(u,v)}; si aggiunge l'arco all'insieme degli archi
      2. UNION(u, v); ora u e v fanno parte dello stesso albero
    2. ; se u e v appartengono allo stesso set non fa niente, perché appartengono già allo stesso albero

Domande

L'algoritmo converge? Sì, perché è composto solo da due cicli for, che per definizione convergono.

L'algoritmo è corretto? Sì, perché rispecchia quello che prescrive il teorema master degli MST.

Qual è la complessità dell'algoritmo?

1: O(1)

2: Θ(v) // inizializzazione

3: O(a log a) // ordinamento

4: O(a) // ciclo for

4.1: O(log a) // find set

4.2: O(1) // union

O(1) + O(v) + O(a log a) + O(a log a) + O(1) ⇒ O(a log a), dato che a domina su n e 1 è costante

Prim

L'algoritmo di Prim utilizza due strutture dati aggiuntive: per ogni vertice aggiunge un'informazione sul vertice "padre" π(v) e inserisce ogni vertice in un min-heap binario.

Inizializza tutti i vertici in modo che il loro peso sia ∞ e che i padri siano tutti NIL.
Poi estrae il vertice "root" e assegna 0 come peso.

A questo punto il vertice root non è più presente nell'heap (l'estrazione da un heap binario ha costo di Θ(n)).
A ogni vertice adiacente viene assegnato un peso pari all'arco che lo congiunge al nodo corrente, a meno che il vertice non abbia già un peso e che questo sia minore dell'arco.
Si estrae dall'heap l'elemento minore o - a parità di peso - uno a caso tra gli elementi minori.
Potrebbe succedere che lo stesso vertice sia visitato più volte, e che in una visita successiva l'arco abbia un peso minore di quello già attribuito al vertice. In questo caso, il vertice "cambia di padre" e gli viene assegnato il nuovo peso.
L'algoritmo di Prim è quindi

  1. Q ← V(G) // assegno all'heap i vertici di G
  2. foreach u ∈ Q, key(u) = ∞
  3. key(r) = 0

Visita di un grafo

Visita in ampiezza

La visita in ampiezza si basa sull'assegnazione a ogni vertice di un colore tra bianco, grigio e nero: il bianco indica che il vertice non è ancora stato visitato, il grigio indica che è stato visitato, ma non sono stati visitati tutti i suoi vertici adiacenti, il nero indica che è stato visitato il vertice e tutti i vertici adiacenti.

La visita in ampiezza sfrutta alcune strutture dati di appoggio:

  • il colore di un vertice è memorizzato in una lista in cui la chiave è il vertice e il valore è il colore: color[v]
  • il predecessore di un vertice è memorizzato in una lista in cui la chiave è il vertice e il valore è il predecessore: π[v]
  • la distanza dal vertice sorgente è memorizzata in una lista in cui la chiave è il vertice e il valore è un numero naturale: d[v]
  • l'elenco di vertici grigi è una coda FIFO: Q

L'algoritmo opera così:

  1. for each v in G inizializzazione dei vertici (tranne il sorgente)
    1. color[v] = white; imposto il colore bianco
    2. π[v] = NIL; il predecessore del vertice è vuoto
    3. d[v] = ∞; la distanza dalla sorgente è infinita
  2. inizializzazione del vertice sorgente
    1. color[s] = grey; imposto il colore grigio
    2. π[s] = NIL; il predecessore della sorgente è NIL (resterà così)
    3. d[s] = 0; la distanza dalla sorgente è zero (resterà così)
  3. Q ↞ s; aggiungo alla FIFO l'elemento sorgente
  4. while Q ≠ ∅, Q ↠ v; finché la coda Q non è vuota estraggo l'elemento in coda
      1. for each u = adj(v); per ogni vertice u adiacente a v
        1. if color[u] == white; se il colore di u è bianco
          1. color[u] = grey; imposta il colore grigio a u
          2. d[u] = d[v] + 1; la distanza di u è uno più di v
          3. π[u]= v; il predecessore di u è v
          4. Q ↞ u; accoda u all'elenco di vertici grigi
      2. color[v] = black; imposta il colore di v a nero

Il tempo di esecuzione di questo algoritmo è O(V+E), poiché ogni vertice e "toccato" una sola volta e ogni arco è "toccato" al massimo una volta.

Siccome se un nodo è già stato visitato non si visitano più i suoi archi, la visita in ampiezza crea, di fatto, un albero.

Visita in profondità

La visita in profondità inizia dal nodo sorgente e lo marca in grigio, poi non lo marca in nero finché non sono neri tutti i suoi successori, e così via ricorsivamente.

L'algoritmo ricorda il modo per uscire sempre da un labirinto (senza isole), che consiste nel procedere con una mano sempre appoggiata al lato (es. mano destra tocca sempre la parete destra).

L'algoritmo è composto da una parte di inizializzazione e lancio della parte ricorsiva e una seconda parte che richiama se stessa nei suoi sotto-vertici:

function DFS

  1. for each v in G inizializzazione dei vertici (tranne il sorgente)
    1. color[v] = white; imposto il colore bianco
    2. π[v] = NIL; il predecessore del vertice è vuoto
    3. time = 0; informazione sull'ordine di esecuzione
  2. for each v in adj(s) richiama la visita su ogni nodo adiacente
    1. if color[v] == white, DFG-visit(v)

function DFS-visit(v)

  1. color[v] = grey;
  2. time = time + 1;
  3. d[v] = time;
  4. for each u in adj(v)
    1. if color[u] == white
      1. π[u] = v;
      2. DFS-visit(u)
  5. color[v] = black;
  6. time = time + 1
  7. f[v] = time

Il tempo di esecuzione di questa funzione è Θ(V+E).

Una proprietà importante di questa visita è che l'ordine dei vertici visitati potrebbe ricalcare uno schema "a parentesi", cioè se apriamo una parentesi ogni volta che visitiamo un vertice e la chiudiamo ogni volta che finsice la visita, otteniamo una struttura a parentesi "ben formato".

Un'altra proprietà è che a seconda del colore che troviamo possiamo classificare gli archi:

  • se l'arco è collegato a un vertice white, abbiamo un arco dell'albero
  • se l'arco è collegato a un vertice grey, abbiamo un arco all'indietro
  • se l'arco(u, v) è collegato a un vertice black, abbiamo un arco in avanti (se d[u] < d[v]) o di attraversamento (se d[u] > d[v])

Topological sort

Utilizzando la DFS è possibile ordinare i vertici di un grafo in base all'ordine in cui sono disposti.

Grafici aciclici

Se una visita in profondità non incontra mai vertici grey, il grafo è aciclico.

 

I Grafi / introduzione

Grafi

Grafi e rappresentazioni

I grafi sono strutture dati caratterizzate dalla presenza di vertici e archi e sono solitamente rappresentati da G(V, E), in cui G è il grafico, V è l'insieme dei vertici e E - senza troppa fantasia - l'insieme degli archi.

I grafi solitamente sono rappresentati o da matrici o da liste. Se sono rappresentati da matrici, la memoria utilizzata è Θ(V2), perché ogni possibile arco è rappresentato, e il valore all'incrocio tra i due vertici è 1 se l'arco esiste, 0 altrimenti.

V1 V2 V3 V4 V5
V1 0 1 1 0 0
V2 1 1 0 0 0
V3 1 0 0 1 1
V4 0 0 1 0 0
V5 0 0 1 0 0

In questo esempio i vertici sono (V1, V2, V3, V4, V5) e gli archi sono ((V1, V2), (V2, V2), (V1, V3), (V3, V4), (V3, V5)).

Se il grafo è semplice, la matrice deve essere trasponibile senza che il risultato cambi; in altre parole, l'informazione di un "triangolo" di dati pari a V*(V-1)/2 è ridondante, perché l'arco (V1, V2) e (V2, V1) sono la stessa cosa:

V1 V2 V3 V4 V5
V1 0 1 1 0 0
V2 1 1 0 0
V3 1 0 0 1 1
V4 0 0 1 0 0
V5 0 0 1 0 0

Se invece gli archi hanno una direzione - come ad esempio le strade a senso unico, che vanno da V1 a V2 ma non viceversa - tutti gli archi sono importanti perché rappresentano un'informazione univoca.

La rappresentazione tramite liste di adiacenza invece contengono una lista di vertici, a ciascuno dei quali corrisponde una lista di altri vertici a cui sono collegati.
Il grafo riportato sopra sarebbe:

  • V1 → V2 → V3
  • V2 → V1 → V2
  • V3 → V1 → V4 → V5
  • V4 → V3
  • V5 → V3

Anche in questo caso, se il grafo non è orientato, alcune informazioni sono ridondanti (es. il collegamento tra V1 e V2 è rappresentato sia nella lista V1 sia nella V2), mentre nel caso di grafi orientati i due collegamenti rappresenterebbero entità diverse e non ci sarebbe ridondanza.

Visita del grafo

La visita del grafo permette, partendo da un vertice, di "toccare" tutti gli altri vertici collegati direttamente o indirettamente.

Si comincia da una sorgente, come nel problema dei flussi di Ricerca Operativa, e si visitano tutti i nodi collegati. Per fare questo, si colorano i nodi dapprima di bianco, poi in grigio, se sono stati visitati, e in nero se tutti i nodi adiacenti sono a loro volta stati visitati (sono in grigio o in nero).

Il seguito nel post Visita di un grafo.

 

Compiti sui tempi di esecuzione

27/05/2015

Si confrontino le seguenti funzioni utilizzando le relazioni O, Ω e Θ (giustificando le risposte):

f(n) g(n)
10n3 + log n + 2n n2 + log n5
n! 2n
2n 2n+100
Svolgimento
10n3 + log n + 2n n2 + log n5

In questo caso è possibile considerare solo il grado più alto di entrambe le espressioni. Siccome n2 + log n5 si può scrivere come n2 + 5log n, il confronto è tra

f(n) = n3 e g(n) = n2

quindi f(n) = Ω(g(n))

n! 2n

In questo caso è opportuno iniziare a sviluppare le serie per capire quale cresce più velocemente.

n! = n * (n-1)! = n * (n-1) * (n-2)! eccetera

2n = 2 * 2n-1 = 2 * 2 * 2n-2 eccetera

per n = 0, n! = 1 (per definizione) e 20 = 1

per n = 1, n! = 1 (per definizione) e 21 = 2

per n = 2, n! = 2 (per definizione) e 22 = 4

per n = 3, n! = 6 (per definizione) e 23 = 8

per n = 4, n! = 24 (per definizione) e 24 = 16

da questo punto in poi, ogni n contribuisce a moltiplicare n in n! e 2 in 2n

Quindi possiamo dire che per n0 = 5 vale che f(n) = Ω(g(n))

2n 2n+100

Questo caso è semplicemente f(n) = Θ(g(n)) con c1 = c2 = 2100

28/01/2015

Si risolvano le seguenti ricorrenze:

  • T(n) = 3T(n/2) + n2
  • T(n) = 4T(n/2) + n2
  • T(n) = T(n/2) + 2n
Svolgimento

Si può utilizzare per tutti e tre i problemi il teorema master per la ricorsione:

nlog23 ≈ n1,1 < n2

in questo problema f(n) =n2, g(n) = n1,1+ε, con 0 < ε < ∼0,9, quindi f(n) = Ω(g(n))

Rientriamo nel terzo caso, quindi dobbiamo accertarci che esista un c < 1 t.c. af(n/b) < cf(n), quindi 3(n/2)2 = 3/4 n2 < cn2. Scegliendo un 3/4 < c < 1 si ha che cn2 è sempre maggiore.

Perciò il costo è T(n) = Ω(f(n)) = Ω(n2)

Nel secondo problema nlog24 = n2
Rinetriamo nel terzo caso, quindi T(n) = Θ(n2 ln n), poiché l'algoritmo deve eseguire n2 operazioni per l'altezza dell'albero.

Nel terzo problema nlog21 = n0 = 1 < 2n
Quindi f(n) = 2n = Ω(1)

Siamo ancora nel terzo caso, si può dire che T(n) = Θ(2n) a patto che 2n/2 < c2n con 1/2 < c < 1.

23/01/2014

Si definiscano formalmente le relazioni O, Ω, Θ e, utilizzando le definizioni date e
nient’altro, si dimostri la verità o la falsità di ciascuna delle seguenti affermazioni:

  1. n logn = Θ(n2)
  2. n + √3n = Ω(logn)
  3. 4n log n = Ο(4n + log n2)
  4. 2n+k = Ο(2n ), dove k è una costante intera positiva
  5. 2n+n = Ο(2n)
Svolgimento

Si dice f(n) = O(g(n)) se ∃ c > 0 tale che 0 ≤ cf(n) ≤ g(n)∀ n ≫ n0

Si dice f(n) = Ω(g(n)) se ∃ c > 0 tale che 0 ≤ g(n) ≤ cf(n) ∀ n ≫ n0

Si dice f(n) = Θ(g(n)) se ∃ c1, c2 > 0 tale che 0 ≤ c1f(n) ≤ g(n) ≤ c2f(n) ∀ n ≫ n0

  • n logn = Θ(n2)

f(n) = o(g(n)) perché 0 < n logn < cn2 ⇒ 0 < logn < cn
che è sempre vera per n ≫ n0

Quindi il primo problema è falso.

  • n + √3n = Ω(logn)

Se il secondo problema è vero, esiste c > 0 tale che 0 ≤ log n ≤ c(n+√3n)

Si può scrivere come elog n < ec(n+√3n)
cioè n < ecn(1+√3)
Evidentemente c può "assorbire" (1 + √3), basta trovare un c1 = c + √3c; con c > 0 la crescita di ecn è esponenziale, e quindi tende a essere più grande di n per valori grandi di n.

In alternativa, utilizzando il teorema per cui f(n) = Ω(g(n)) ⇒

limnfngn=

abbiamo che

limnn+√3nlogn=Hlimn1+√31n=limnn+√3n=

quindi il secondo problema è vero.

  • 4n log n = Ο(4n + log n2)

Il terzo problema è un confronto tra 4n logn e 4n + 2log n, che si può riscrivere, dividendo per 4n, come

log n = O(1 + (log n)/2n)

Per la definizione di O, il problema è vero se esiste c tale che c(log n) ≤ 1 + (log n)/2n

Semplificando ancora, dividendo per log n, si ottiene c ≤ 1/log n + 1/2n

Per n molto grande, 1/log n tende a zero, ma 1/2 n tende a infinito, mentre c è una costante. Di conseguenza il terzo problema è vero.

  • 2n+k = Ο(2n), dove k è una costante intera positiva

Questo è vero se esiste c > 0 tale che c(2n+k) ≤ 2n. Scegliendo c < 1 si ha che quando h*c < 2n il problema è vero, e siccome h e c sono costanti, mentre 2n tende a infinito, il problema è vero.

  • 2n+n = Ο(2n)

Riscrivendo il problema come 2n2n = Ο(2n), si ha che perché il problema sia vero deve essere che 0 ≤ 2n2n ≤ 2n, ma semplificando si ha 0 ≤ 2n 1, che è palesemente falso.

Algoritmi di ordinamento (con implementazione in C)

Premessa

Tutti gli algoritmi usano il seguente codice:

#include <stdio.h>
#define NUM_OF_ELEMENTS 10000
main() {
	int i;
	int numeri[NUM_OF_ELEMENTS];
	srand(time(NULL));
	for(i = 0; i < NUM_OF_ELEMENTS; i++) {
		numeri[i] = rand();
	}
	for(i = 0; i < NUM_OF_ELEMENTS; i++) {
		printf("%d ", numeri[i]);
		if(i % 5 == 4) printf("\n");
		}
	printf("\n\n");
	sort(&numeri, NUM_OF_ELEMENTS);
	for(i = 0; i < NUM_OF_ELEMENTS; i++) {
		printf("%d ", numeri[i]);
		if(i % 5 == 4) printf("\n");
	}
	getchar();
}

In alcuni casi la chiamata alla funzione sort può avere una firma leggermente diversa (es. possono avere come parametro l'indice di partenza, tipicamente zero).

Algoritmi con costo di esecuzione O(n2)

Tipicamente gli algoritmi di ordinamento con costo di esecuzione O(n2) sono caratterizzati da una reiterazione di tutti gli elementi per ogni "sistemazione".

Selection sort

L'algoritmo inizia con un elemento, che per definizione è orditato.

Dal secondo elemento in poi, abbiamo un insieme già ordinato - convenzionalmente a sinistra - e un insieme da ordinare - a destra.

Per ciascun elemento non ancora ordinato, si sceglie il minore tra gli elementi non ancora ordinati e si scambia con quello più a sinistra.

1 3 4 7 8 10 29 44 14 73 19 36
1 3 4 7 8 10 29 44 14 73 19 36
1 3 4 7 8 10 14 44 29 73 19 36
1 3 4 7 8 10 14 44 29 73 19 36

Il tempo di esecuzione si può calcolare dividendo l'array in due.

Per ciascun elemento già ordinato si sottrae uno al totale di elementi da ordinare, quindi con n elementi la prima iterazione sarà tutti gli elementi (n), la seconda su tutti tranne il primo (n-1) e così via, fino a fare n iterazioni su

j=1nn-j

Il totale delle iterazioni è il ben noto

nn-12

Apparentemente è un tempo migliore di n2, ma a ben guardare il limite per n sufficientemente grande di

limnnn-12n2=limnn22-n2n2

Dividendo il numeratore e il denominatore per n2,

limn12-12n

Si ottiene che per n sufficientemente grande, l'algoritmo tende a un costo di Θ(n2) con costante = 1/2.

Il tempo di esecuzione non varia per il caso medio, ottimo e pessimo.

L'implementazione può essere fatta in loco.

void sort(int *numeri, int conteggio) {
	int i;
	int j;
	int m;
	int temp;
	for(i = 0; i < conteggio-1; i++) {
		m = i;
		for(j = i+2; j < conteggio; j++) {
			if(numeri[j] < numeri[m]) m = j;
		}
		temp = numeri[m];
		numeri[m] = numeri[i+1];
		numeri[i] = temp;
	}
}

Insertion Sort

L'algoritmo inizia con un elemento, che per definizione è orditato.

Dal secondo elemento in poi, abbiamo un insieme già ordinato - convenzionalmente a sinistra - e un insieme da ordinare - a destra.

Per ciascun elemento non ancora ordinato, si sceglie quello più a sinistra e si scorre da sinistra a destra l'insieme ordinato fino a trovare il primo elemento maggiore dell'elemento considerato.

A questo punto si scorrono a destra di una posizione tutti gli elementi maggiori e si inserisce l'elemento considerato nella posizione lasciata libera.

1 18 27 44 89 101 29 45 14 73 19 36
1 18 27
44

89

101
29 45 14 73 19 36

il 29 è memorizzato in una variabile

1 18 27 * 44 89 101 45 14 73 19 36

* questa posizione è ancora occupata dal 44, ma ai fini dell'algoritmo è libera

1 18 27 29 44 89 101 45 14 73 19 36

Il tempo di esecuzione si può discutere in maniera estremamente simile a Selection Sort.

L'implementazione può essere fatta in loco.

void sort(int *numeri, int conteggio) {
	int i;
	int j;
	int k;
	int num;
	for(i = 0; i < conteggio-1; i++) {
		num = numeri[i+1];
		for(j = 0; j <= i; j++) { if(numeri[j] > num) {
				for(k = i+1; k > j; k--) {
					numeri[k] = numeri[k-1];
				}
				numeri[j] = num;
				break;
			}
		}
	}
}

Bubble Sort

L'algoritmo inizia con un elemento, che per definizione è orditato.

Dal secondo elemento in poi, abbiamo un insieme già ordinato - convenzionalmente a sinistra - e un insieme da ordinare - a destra.

Per ciascun elemento non ancora ordinato, si sceglie quello più a sinistra e si scambia con tutti quelli già ordinati che sono maggiori dell'elemento considerato.
È maggiore il 32 o il 18? Si fa lo scambio!

32 18 45 14 73 19 36
18 32 14 45 19 73 36

È maggiore il 32 o il 45? Siccome è il 45, non c'è scambio e l'indice si incrementa.

18 14 32 45 19 73 36
18 14 32 19 45 73 36
18 14 32 19 45 36 73

Anche in questo caso il tempo di esecuzione è Θ(n2), poiché per n volte posizioniamo l'n-k-esimo elemento a destra.

L'implementazione può essere fatta in loco.

void sort(int *numeri, int conteggio) {
	short scambio;
	int i;
	int j;
	int temp;
	scambio = 0;
	for(i = 0; i < conteggio - 1; i++) {
		for(j = 1; j < conteggio - i + 1; j++) { if(numeri[j-1] > numeri[j]) {
				temp = numeri[j-1];
				numeri[j-1] = numeri[j];
				numeri[j] = temp;
				scambio = 1;
			}
		}
		if(scambio == 0) break;
	}
}

Algoritmi con costo di esecuzione O(n logn)

Gli algoritmi con questo tempo di esecuzione sono i divide-et-impera, algoritmi in cui il vettore viene diviso (tipicamente in due) tante volte fino ad arrivare a blocchi con uno o due soli elementi.

Merge Sort

Il vettore viene spezzato a metà ricorsivamente, fino ad arrivare a piccoli blocchi di uno o due elementi. Successivamente tutti gli elementi, ordinati al loro interno, sono fusi (merge) in un unico blocco grande (circa) il doppio.

V1 1 4 14 19 29 36 44 73
V2 3 7 11 21 29 39 52
Vettore risultante

La freccia indica la posizione del puntatore, il grigio indica che il numero è il più basso tra i due

V1 ⇒1 4 14 19 29 36 44 73
V2 ⇒3 7 11 21 29 39 52
Vettore risultante 1

 

V1 1 ⇒4 14 19 29 36 44 73
V2 ⇒3 7 11 21 29 39 52
Vettore risultante 1 3

 

V1 1 ⇒4 14 19 29 36 44 73
V2 3 ⇒7 11 21 29 39 52
Vettore risultante 1 3 4

...eccetera...

V1 1 4 14 19 29 36 44 ⇒73
V2 3 7 11 21 29 39 52 ⇒ *

* il puntatore ha superato i limiti, quindi è sufficiente copiare il contenuto dell'altro array

Vettore risultante 1 3 4 7 11 14 19 21 29 29 36 39 44 52

Il tempo di esecuzione del merge è C(n) = n-1 + 2C(n/2). Per il teorema master, O(nlog22) = O(n-1) ⇒ C(n) = Θ(n logn)

L'implementazione può essere fatta con un vettore di appoggio.

void merge(int * numeri, int inizio, int pivot, int conteggio) {
	int * merged;
	int i;
	int ja, jb;
	ja = inizio;
	jb = pivot;
	merged = malloc(sizeof(int) * (conteggio - inizio));

	for(i = inizio; i < conteggio; i++) {
		if((numeri[ja] < numeri[jb] || jb >= conteggio) && ja < pivot)
			merged[i-inizio] = numeri[ja++];
		else
			merged[i-inizio] = numeri[jb++];
	}
	for(i = inizio; i < conteggio; i++) numeri[i] = merged[i-inizio];
	free(merged);
}
void sort(int *numeri, int inizio, int conteggio, int rec) {
	int pivot;
	int i;
	if(conteggio - inizio > 1) {
		pivot = (conteggio - inizio) / 2 + inizio;
		sort(numeri, inizio, pivot, rec+1);
		sort(numeri, pivot, conteggio, rec+1);
		merge(numeri, inizio, pivot, conteggio);
	}
}

Quick Sort

Quick Sort è un algoritmo in cui il grosso del costo consiste nella fase del divide, perché richiede di scegliere un elemento pivot e di spostare tutti gli elementi minori o uguali del pivot a sinistra e tutti quelli maggiori a destra, e di applicare ricorsivamente alle due metà l'algoritmo fino a ottenere blocchi di uno o due elementi.

Limiti e caratteristiche del Quick Sort

Quick Sort richiede la scelta di un elemento, contenuto nella parte di vettore che stiamo ordinando, che sia "circa" mediano, in modo da ottenere due parti "circa" uguali. Scegliendo un elemento completamente a caso, la probabilità seguirà la classica curva gaussiana, e la probabilità di scegliere il minimo o il massimo è estremamente bassa.

Però il Quick Sort processa per la maggior parte dei casi vettori di pochi elementi, in cui la probabilità di scegliere il minimo o il massimo è non trascurabile.

Scegliendo tre elementi a caso - e non uno - e utilizzando come pivot la mediana, si riduce in modo esponenziale la probabilità di scegliere un estremo (p.e. se c'è 1/100 probabilità di ottenere un minimo o un massimo, scegliendo tra tre numeri la probabilità diventa 1/100 * 1/100 * 1/100 = 1/1.000.000).

Quick Sort richiede un "aggiustamento" nel caso in cui ci possano essere molti valori duplicati. In questo caso ci si potrebbe trovare con semi-array contenenti sempre lo stesso numero, e in questo caso il divide porterebbe a un semi-array uguale a quello originale e un altro semi-array vuoto, all'infinito.

Per questo è necessario prevedere un controllo che tutti gli elementi dell'array siano non tutti uguali (basta un elemento diverso).

Esempio d'uso

Nota: gli elementi inseriti nella funzione med (=mediana) sono scelti casualmente.

52 4 19 36 73 29 21 44 1 39 3 7 11 29 14

Pivot: med(39, 29, 36) = 36

Ricorsione: sinistra

4 19 36 29 21 1 3 7 11 29 14

Pivot: med(19, 7, 11) = 11

Ricorsione: sinistra-sinistra

4 1 3 7 11

Pivot: med(1, 3, 7) = 3

Ricorsione: sinistra-sinistra-sinistra

1 3

A questo punto, se gli elementi non sono ordinati si scambiano.
In questo caso, 1, 3

Ricorsione: sinistra-sinistra-destra

4 7 11

A questo punto, la mediana è 7, che divide l'array in 4, 7 e 11.

4 e 7 non vengono scambiati perché sono già in ordine e 11 resta da solo. Fondendo i due array si ottiene 4, 7, 11

Ricorsione: sinistra-destra

19 36 29 21 29 14

Pivot: med(19, 21, 14) = 21

Ricorsione: sinistra-destra-sinistra

19 21 14

A questo punto, la mediana è 19, si ottengono 19, 14 e 21.
19 e 14 vengono scambiati e si ottiene 14, 19, 21

Ricorsione: sinistra-destra-destra

36 29 29

A questo punto, la mediana è 29. Si ottengono 29, 29 e 36.
29 e 29 sono uguali, quindi non avviene alcuno scambio e si restituisce la coppia di numeri.
Il risultato è 29, 29, 36

Ricorsione: destra

52 73 44 39

Pivot: med(52, 73, 44) = 52

Ricorsione: destra-sinistra

52 44 39

La mediana è 44, si ottengono 44, 39 e 52.
44 e 29 si scambiano, e unendo l'elemento singolo 52 si ottiene 39, 44, 52

Ricorsione: destra-destra
Si ottiene l'elemento unico 73.

Unendo nell'ordine di chiamata, dal ramo più a sinistra a quello più a destra, si ottiene

1 3 4 7 11 14 19 21 29 29 36 39 44 52 73

Il tempo di esecuzione del Quick Sort è O(n logn).

// NOTA: Questa implementazione funziona solo con valori distinti
// NOTA: Questa implementazione non utilizza il sistema della mediata descritto nel testo

void sort(int * a, int start, int end) {
	int s, e; // inizializzati a start e end, scorrono l'array dall'inizio o dalla fine
	int pivot; // l'elemento pivot. Nella prima implementazione, è un elemento scelto completamente a caso
	int i, tmp, r;
	
	// gestisco il caso di un array di 1 e 2 elementi
	if(start >= end) return;
	if(start + 1 == end) {
		if(a[start] > a[end]) {
			tmp = a[start];
			a[start] = a[end];
			a[end] = tmp;
		}
		return;
	}
	
	srand(time(NULL));
	
	s = start;
	e = end;
	srand(time(NULL));
	r = (rand() % (end-start+1)) + start;
	pivot = a[r]; // non è un'implementazione equiprobabile, ma per i nostri scopi è più che sufficiente
	while(s < e) {
		if(a[s] <= pivot) s++; else { if(a[e] > pivot) e--;
			if(a[s] > pivot && a[e] <= pivot) { tmp = a[s]; a[s] = a[e]; a[e] = tmp; } } } if(a[s] > a[e]) {
		tmp = a[s];
		a[s] = a[e];
		a[e] = tmp;
	}
	if(a[s] <= pivot) e++; sort(a, start, e-1); if(e >= s) sort(a, e, end);
}

Algoritmi con costo di esecuzione O(n) e simili

Integer Sort

Integer Sort ha tempi migliori degli algoritmi precedenti perché suppone che gli elementi da ordinare siano tutti numeri interi positivi (in realtà è facile includere nell'algoritmo anche quelli negativi).

L'idea è di creare un array che utilizzi gli interi da ordinare come degli indici e l'elemento del secondo array incrementa semplicemente l'indice ogni volta che si incontra quel numero.

Per esempio:

5, 9, 13, 13, 6, 9, 5, 14, 2 è l'array da ordinare.

max(array) = 14, quindi si crea un array di 14 elementi (per semplicità sarà indicizzato da 1 a n, contro la prassi degli array) e si inizializzano i contatori a zero.

[0] [0] [0] [0] [0] [0] [0] [0] [0] [0] [0] [0] [0] [0]

Per ogni numero esaminato si incrementa il rispettivo contatore:

5

[0] [0] [0] [0] [1] [0] [0] [0] [0] [0] [0] [0] [0] [0]

9

[0] [0] [0] [0] [1] [0] [0] [0] [1] [0] [0] [0] [0] [0]

13

[0] [0] [0] [0] [1] [0] [0] [0] [1] [0] [0] [0] [1] [0]

13

[0] [0] [0] [0] [1] [0] [0] [0] [1] [0] [0] [0] [2] [0]

...eccetera. L'ordine finale è il seguente:

[0] [1] [0] [0] [2] [1] [0] [0] [2] [0] [0] [0] [2] [1]

Scorrendo l'array si ottiene

2, 5, 5, 6, 9, 9, 13, 13, 14

Il tempo di esecuzione dipende dagli n elementi e dal k massimo, ed è O(n+k).

L'implementazione è la seguente:

sort(int * a, int count) {
	int i, k, max;
	int * appo;
	
	max = 0;
	k = 0;
	
	for(i = 0; i < count; i++) if(max < a[i]) max = a[i];
	
	appo = malloc(sizeof(int) * max + 1);
	for(i = 0; i <= max; i++) appo[i] = 0;
	for(i = 0; i < count; i++) appo[a[i]]++;
	for(i = 0; i <= max; i++) while(appo[i] > 0) {a[k++] = i; appo[i]--;}
}

Compiti ASD

Tempi di esecuzione (teoria)

Alcune soluzioni si trovano qui.

27/05/2015

Si confrontino le seguenti funzioni utilizzando le relazioni O, Ω e Θ (giustificando le risposte):

f(n) g(n)
10n3 + log n + 2n n2 + log n5
n! 2n
2n 2n+100

28/01/2015

Si risolvano le seguenti ricorrenze:

  • T(n) = 3T(n/2) + n2
  • T(n) = 4T(n/2) + n2
  • T(n) = T(n/2) + 2n

23/01/2014

Si definiscano formalmente le relazioni O, Ω, Θ e, utilizzando le definizioni date e
nient’altro, si dimostri la verità o la falsità di ciascuna delle seguenti affermazioni:

  1. n logn = Θ(n2)
  2. n + √3n = Ω(logn)
  3. 4n log n = Ο(4n + log n2)
  4. 2n+k = Ο(2n ), dove k è una costante intera positiva
  5. 2n+n = Ο(2n)

08/09/2014

Per un certo problema sono stati trovati due algoritmi risolutivi (A1 e A2) con i seguenti tempi di esecuzione:

A1 : T(n) = 3·T(n/2) + n2
A2 : T(n) = 4·T(n/2) + n2

Si dica, giustificando tecnicamente la risposta, quale dei due algoritmi è preferibile per input di dimensione sufficientemente grande.

12/06/2014

Si confrontino le seguenti funzioni utilizzando le relazioni O, Ω e Θ:

f(n) g(n)
100 n + log n n + (log n)2
log n log n3
2n 3n

01/09/2015

Utilizzando la definizione di O, e nient’altro, si stabilisca se le seguente affermazioni sono vere o false:
a) f(n) = O(n)
b) f(n) = O(n2)
dove f(n) = (2n − 1) / 2.

Costi di esecuzione (strutture dati)

27/05/2015

Completare la seguente tabella indicando la complessità delle operazioni che si riferiscono a un dizionario di n elementi. Per l’operazione Successore si assuma di essere sull’elemento x a cui si applica l’operazione.

Ricerca Massimo Successore Minimo Costruzione
Albero binario di ricerca
Min-Heap

Soluzione: https://diario.softml.it/costi-di-esecuzione-strutture-dati-27-05-2015/

Funzioni efficienti (grafi)

27/05/2017

Il seguente algoritmo accetta in ingresso la matrice di adiacenza di un grafo non orientato e restituisce un valore Booleano (TRUE / FALSE)

MyAlgorithm( A )
1. n = rows(A) /* determina il numero di vertici del grafo */
2. let B an n x n matrix /* alloca memoria per una matrice B n x n */
3. for i = 1 to n
4.   for j = 1 to n
5.     b[i,j] = 0
6.     for k = 1 to n
7.       b[i,j] = b[i,j] + a[i,k]*a[k,j]
8. for i = 1 to n
10.  for j = i + 1 to n
11.     if a[i,j]*b[i,j] <> 0 then
12.return FALSE
13.return TRUE

Si calcoli la complessità computazionale di MyAlgorithm e si determini la sua funzione (in quali casi restituisce TRUE? in quali casi FALSE?). (Nota: Nel determinare la   complessità si ignori per comodità la complessità delle istruzioni 1 e 2).
Si simuli inoltre il suo comportamento sui seguenti due grafi, verificando che restituisca il risultato atteso:

La soluzione è riportata qui: https://diario.softml.it/funzioni-efficienti-grafi-27-05-2017/

28/01/2015

Si scriva l’algoritmo di Floyd-Warshall, si dimostri la sua correttezza, si fornisca la sua complessità computazionale e si simuli accuratamente la sua esecuzione sul seguente grafo:

28/01/2015

Un piroscafo collega regolarmente n città portuali. Ad ogni porto u il proprietario del piroscafo ricava P(u) euro, ma per andare dal porto u al porto v spende in totale C(u,v) euro. Se P(u)/C(u,v) > 1, il proprietario del piroscafo ha quindi realizzato un guadagno. Si vuole stabilire se esiste un itinerario ciclico di k città (x0, x1, ..., xk = x0) per il quale

i=1kPxii=1kCxi-1xi

dove μ ≥ 1 è una quantità stabilita a priori che rappresenta il guadagno minimo che il proprietario del piroscafo intende realizzare.

Si scriva un algoritmo che accetti in ingresso i ricavi P per ogni porto, i costi C per ogni coppia di porti e la costante μ, e stabilisca se un tale itinerario esiste o meno. Si discuta la correttezza dell'algoritmo proposto e la sua complessità computazionale.

08/09/2014-1

Sia G = (V, E) un grafo orientato e pesato, sia s∈V un vertice “sorgente” e si supponga che G sia stato inizializzato con INIT-SINGLE-SOURCE(G, s). Si stabilisca se la seguente affermazione è vera o falsa: «Se G non contiene un ciclo di peso negativo raggiungibile dalla sorgente s, allora nessuna sequenza di passi di rilassamento potrà assegnare al campo d[s] un valore diverso da 0.»

Nel primo caso si fornisca una dimostrazione, nel secondo un controesempio.

08/09/2014-2

Si vuole costruire una rete stradale che colleghi cinque città (A-E), minimizzando i costi complessivi di realizzazione. I costi per la costruzione di una strada tra due città sono sintetizzati nella seguente tabella (dove +∞ significa che la strada è irrealizzabile):

A B C D E
A 0 3 5 11 9
B 3 0 3 9 8
C 5 3 0 +∞ 10
D 11 9 +∞ 0 7
E 9 8 10 7 0

Si formuli il problema dato in termini di un problema di ottimizzazione su grafi, e si descriva un algoritmo per la sua soluzione discutendone correttezza e complessità. Infine, si simuli accuratamente l’algoritmo presentato per determinare una soluzione del problema.

29/05/2014

Si scriva un algoritmo di complessità O(n2 log n) per determinare le distanze tra tutte le coppie di vertici in un grafo sparso G avente pesi sugli archi positivi, dove n è il numero di vertici in G.

12/09/2016

Si stabilisca quale problema risolve il seguente algoritmo, che accetta in ingresso un grafo non orientato G = (V, E):

MyAlgorithm(G)
1. A = ∅
2. ordina i vertici di G per grado crescente
3. for each u ∈ V[G] do /* vertici estratti secondo l’ordine stabilito nel passo 2 */
4.    if MyFunction(G,A,u) then
5.       A = A ∪ {u}
6. return A
MyFunction(G,A,u)
1. for each v ∈ A do
2.    if (u,v) ∈ E[G] then
3.       return FALSE
4. return TRUE

Si dimostri la correttezza dell’algoritmo, si determini la sua complessità e si simuli infine la sua esecuzione sul seguente grafo:

01/09/2015

Il seguente algoritmo accetta in ingresso la matrice di adiacenza A di un grafo orientato ed un intero k (con k ≥ 2), e restituisce un valore Booleano (TRUE / FALSE):

MyAlgorithm(A,k)
1. n = rows(A) /* determina il numero di vertici del grafo */
2. let B be the n x n identity matrix
3. for i = 1 to k - 1
4.   B = MyFunction(A,B)
5.   for i = 1 to n
6.     for j = i + 1 to n
7.       if B[i,j]*A[j,i] ≠ 0 then
8.       return FALSE
9. return TRUE

L’algoritmo utilizza la seguente funzione che accetta in ingresso due matrici quadrate (di dimensione identica) e restituisce un’altra matrice quadrata della stessa dimensione:

MyFunction(X,Y)
1. n = rows(X)
2. for i = 1 to n
3.   for j = 1 to n
4.     Z[i,j] = 0
5.     for k = 1 to n
6.       Z[i,j] = Z[i,j] + X[i,k]*Y[k,j]
7. return Z

Si calcoli la complessità computazionale complessiva di MyAlgorithm e si determini qual è il problema che risolve (ovvero: in quali casi restituisce TRUE? in quali casi FALSE?). (Nota: Nel determinare la complessità si ignori per comodità la complessità delle istruzioni 1 e 2 di MyAlgorithm e dell’istruzione 1 di MyFunction).

Si simuli inoltre il suo comportamento sui seguenti due grafi, verificando che restituisca il risultato atteso:

Grafi (cammini minimi)

27/05/2015-1 e 12/06/2014 e 12/09/2016

Il problema di determinare i cammini minimi tra tutte le coppie di vertici di un grafo pesato G=(V, E, w) con pesi positivi si può risolvere iterando |V| volte l'algoritmo di Dikstra oppure utlizzando l'algoritmo di Floyd-Warshall. Si riempia la tabella sottostante, specificando le complessità degli algoritmi indicati in funzione della
tipologia di grafo utilizzato:

Grafo sparso Grafo denso
Iterated_Dijkstra (array)
Iterated_Dijkstra (heap)
Floyd-Warshall (27/05/15)
Bellman-Ford (12/06/14)

Supponendo che il grafo sia aciclico, quale algoritmo conviene usare? Perché?

27/05/2015-2

Sia G = (V, E) un grafo non orientato, connesso e pesato e sia (S, V \ S) un taglio di G. Si supponga che l'arco leggero che attraversa il taglio sia unico e lo si denoti con (u,v). In altri termini, per tutti gli altri archi (x,y) che attraversano il taglio, si avrà w(u,v) < w(x,y). Si dimostri che tutti gli alberi di copertura minima di G dovranno necessariamente contenere l'arco (u,v).

28/01/2015

Si determinino due diversi alberi di copertura minimi nel seguente grafo:

12/06/2014-1

Si scriva l’algoritmo di Kruskal, si dimostri la sua correttezza, si fornisca la sua complessità computazionale e si simuli accuratamente la sua esecuzione sul seguente grafo:

12-06-2014-2

Si supponga di voler cambiare una banconota da P euro in monete da a1, a2, …, ak euro. E’ possibile? In caso affermativo, qual è il minimo numero di monete da utilizzare per effettuare il cambio? Per esempio, se si volesse cambiare una banconota da 5 euro in monete da 1 e da 2 euro, sarebbero necessarie almeno 3 monete.

Se si disponesse soltanto di monete da 2 euro, invece, il cambio non sarebbe possibile.

Si formuli questo problema come un problema di cammini minimi su grafi. (Suggerimento: si costruisca un grafo con P + 1 vertici numerati da 0 a P e si inseriscano archi e pesi in modo tale che i cammini dal vertice 0 al vertice P corrispondano a possibili sequenze di monete da utilizzare per il cambio.)

A titolo esemplificativo, si consideri il caso di una banconota da P = 10 euro e monete da a1 = 1 e a2 = 2 euro.

Si costruisca il grafo corrispondente e si utilizzi l’algoritmo di Dijkstra per risolvere il problema.

29/05/2014-1

Si scriva l’algoritmo di Bellman-Ford, si dimostri la sua correttezza, si fornisca la sua complessità computazionale e si simuli accuratamente la sua esecuzione sul seguente grafo (utilizzando il vertice A come sorgente):

29/05/2014-2

Sia G = (V, E) un grafo pesato non orientato e connesso e sia w : E → R la funzione peso.
Sia Tmin un albero di copertura minimo di G e sia T un qualsiasi altro albero di copertura (non necessariamente minimo). Inoltre sia (u,v) ∈ E un arco di peso massimo in Tmin e (x,y) ∈ E un arco di peso massimo in T. Si dimostri che:

w(u,v) ≤ w(x,y).

In altri termini, fra tutti gli alberi di copertura, l’albero di copertura minimo ha il più piccolo arco di peso massimo. (Suggerimemto: si ragioni per assurdo e si usi la classica tecnica del “taglia-e-incolla”.)

12/09/2016

Si determini un albero di copertura minimo nel seguente grafo:

01/09/2015

Sia G = (V, E) un grafo non orientato, connesso e pesato. Dato un taglio (S, V \ S) di G, sia (u,v) un arco che lo attraversa tale che per tutti gli altri archi (x,y) che attraversano il taglio risulta w(u,v) ≤ w(x,y) (arco leggero). Si stabilisca, giustificando formalmente la risposta, se la seguente affermazione è vera o falsa: «Se T è un albero
di copertura di G che non contiene (u,v), allora T non è un albero di copertura minimo.»

Si può affermare la stessa cosa se si assume che tutti i pesi di G siano distinti? Perché?

Nota: nel fornire le giustificazioni non si faccia ricorso al teorema fondamentale degli MST.

Funzioni efficienti (alberi)

27/05/2015

Dato un albero binario, i cui nodi sono colorati di bianco, rosso o verde:

a. scrivere una funzione efficiente che stabilisca se esiste un cammino di tre nodi nell’albero i cui colori formano la bandiera italiana. (Il cammino può partire da un nodo qualsiasi, non necessariamente dalla radice.)

b. Fornire la chiamata della funzione dal programma principale.

c. Analizzare la complessità di tale funzione.

Il tipo Node utilizzato per rappresentare l’albero binario è il seguente:


typedef struct node{
char * colore;
struct node * left;
struct node * right;
} * Node;

28/01/2015

Un nodo di un albero binario u è detto intermedio se la somma delle chiavi contenute nei nodi del sottoalbero di cui u è radice è uguale alla somma delle chiavi contenute nei nodi sul percorso che collega u alla radice dell’albero (u escluso).

a) Scrivere un algoritmo efficiente che restituisca il numero di nodi intermedi.

b) Analizzare la complessità della soluzione trovata. Il tipo Node utilizzato per rappresentare l’albero binario è il seguente:

typedef struct node{
int key;
struct node * left;
struct node * right;
} * Node;

23/01/2014

Dato un albero binario i cui nodi sono colorati di bianco o di nero, scrivere una funzione C efficiente che calcoli il numero di nodi aventi lo stesso numero di discendenti bianchi e neri. (Un nodo è discendente di se stesso.)

Inoltre analizzare la complessità di tale algoritmo.

Il tipo Node utilizzato per rappresentare l’albero binario è il seguente:

typedef struct node{
char * colore;
struct node * left;
struct node * right;
} * Node;

Si può ordinare un dato insieme di n numeri costruendo un albero binario di ricerca che contiene questi numeri (usando ripetutamente Tree-Insert per inserire i numeri uno alla volta) e stampando poi i numeri utilizzando un certo tipo di visita. Scrivere l’algoritmo che realizza questo ordinamento e specificare il tipo di visita effettuata e il relativo algoritmo.

Quali sono i tempi di esecuzione nel caso peggiore e nel caso migliore per questo algoritmo di ordinamento?

08/09/2014

Dato un albero binario, scrivere un procedura efficiente che cancelli il figlio sinistro di ogni nodo se è una foglia e contiene la stessa chiave del nodo padre.

Calcolare la complessità al caso pessimo della funzione indicando la corrispondente relazione di ricorrenza.

La rappresentazione dell’albero binario utilizza esclusivamente i campi left, right e key e il prototipo della procedura è:

void cancella(Node u)

12/06/2014-1

Dato un albero binario di ricerca T, scrivere la funzione Tree-maximum(Node x) che restituisce un nodo con chiave massima di un sottoalbero di T radicato nel nodo x (x è un nodo dell’albero T e x ≠ NULL). Quale è la complessità di questa funzione? Quale è il caso migliore? E il caso peggiore?

12/06/2014-2

Progettare un algoritmo efficiente per stabilire se un albero binario è quasi completo, cioè tutti i livelli dell’albero sono completamente riempiti, tranne eventualmente l’ultimo che ha le foglie addossate a sinistra.

Calcolare la complessità al caso pessimo dell’algoritmo indicando, e risolvendo, la corrispondente relazione di ricorrenza.

La rappresentazione dell’albero binario utilizza esclusivamente i campi left, right e key.

12/09/2016

Scrivere una funzione efficiente check che dato un albero binario di ricerca verifica se è soddisfatta la seguente condizione: per ogni intero k, se le chiavi k e k+2 sono nell'albero allora anche la chiave k+1 è nell'albero.

Analizzare la complessità della funzione. È preferita una soluzione con spazio aggiuntivo O(1).

Funzioni efficienti (divide-et-impera e altro)

28/01/2015

Scrivere un algoritmo efficiente, di tipo divide et impera, che conta il numero di occorrenze della sequenza ‘a’ ‘r’ memorizzata in posizioni adiacenti in un array di caratteri.

Analizzare la complessità indicando e risolvendo la corrispondente relazione di ricorrenza.

Esempio:

Per l’array ‹a, b, c, r› la risposta sarà 0.
Per l’array ‹b, a, r, c, a, r› la risposta sarà 2.

08/09/2014

Progettare un algoritmo efficiente che, dato un array A di n numeri interi e un intero x, determini se esistono due elementi in A (in posizioni diverse) la cui somma è esattamente x.

Calcolare la complessità al caso pessimo dell’algoritmo.

29/05/2014-1

Si determini la complessità asintotica dell’algoritmo AlgA definito come segue:

AlgA(n)
1. s ← 0
2. for i ← 1 to n
3. do s ← s + AlgB(n)
4. return s
AlgB(m)
1. if m = 1
2. then return 0
3. else return B(m/2) + m

29/05/2014-2

Progettare un algoritmo efficiente di tipo divide et impera che dato un vettore di interi restituisce true se tutti i valori sono distinti, false altrimenti. Analizzare la complessità dell’algoritmo proposto.

12/09/2016

Un collezionista di figurine possiede K figurine, non necessariamente distinte. Le figurine vengono stampate con un numero di serie, impresso dal produttore. I numeri di serie sono tutti gli interi compresi tra 1 e N. La collezione sarebbe completa se ci fosse almeno un esemplare di tutte le N figurine prodotte. Purtroppo la collezione non è completa: sono presenti dei duplicati, mentre alcune figurine mancano del tutto. Il vostro compito è di indicare i numeri di serie delle figurine mancanti.

Scrivere un algoritmo efficiente che dato N e l'array S[1..K], ove S[i] è il numero di serie della i- esima figurina posseduta, stampa l'elenco dei numeri di serie mancanti.

L'array S non è ordinato. Ad esempio, se N=10 e S=[1, 4, 2, 7, 10, 2, 1, 4, 3], l'algoritmo deve stampare a video i numeri di serie mancanti 5, 6, 8, 9 (non necessariamente in questo ordine).

Determinare la complessità dell'algoritmo di cui sopra, in funzione di N e K.

Attenzione: K potrebbe essere minore, uguale o maggiore di N.

01/09/2015

Progettare un algoritmo di ordinamento che si comporti come MergeSort, ma che divida ricorsivamente l’array in tre pari anziché due.
a. Scrivere lo pseucodice della nuova procedura MergeSort3.
b. Scrivere lo pseudocodice della nuova procedura Fusione3.
c. Scrivere e risolvere l’equazione di ricorrenza associata.

Hash

28/01/2015

In una tabella Hash di m = 17 posizioni, inizialmente vuota, devono essere inserite le seguenti chiavi numeriche nell’ordine indicato:
18, 36, 155, 19
La tabella è a indirizzamento aperto e la scansione è eseguita per doppio Hashing:
h(k, i) = (k mod m + i * 2k mod 5) mod m
Indicare per ogni chiave le posizioni scandite nella tabella e la posizione finale dove viene allocata.

27/05/2015

Nell’ipotesi di indirizzamento aperto, scrivere uno pseudocodice per HASH-DELETE e modificare HASHINSERT per gestire il valore DELETED. Analizzare la complessità delle due procedure nel caso pessimo.

12/06/2014

In una tabella Hash di m = 17 posizioni, inizialmente vuota, devono essere inserite le seguenti chiavi
numeriche nell’ordine indicato:

23, 40, 15, 58, 85

Indicare per ogni chiave la posizione finale dove viene allocata in questi due casi:

a. Funzione Hash:

h(k) = k mod m

Risoluzione delle collisioni mediante concatenamento

b. La tabella è a indirizzamento aperto e la scansione è eseguita per doppio Hashing:

h(k, i) = (k mod m + i * 2k mod 5) mod m

Si devono indicare tutte le posizioni scandite nella tabella.

Heap

09/09/2014

Dare la definizione di max-heap e dire se ‹23,17,14,6,13,10,1,5,7,12› è un max-heap giustificando la risposta.

29/05/2014

L’operazione Heap-Delete(A, i), cancella l’elemento nel nodo i dall’heap A. Implementare la procedura Heap-Delete in modo che il suo tempo di esecuzione sia O(lg n) per un max-heap di n elementi.

01/09/2015

Supponete di avere un min-heap di n elementi, e di cercare il valore massimo. In quali posizioni del vettore cercate? Giustificare la risposta.

Scrivere un algoritmo che dato un min-heap non vuoto restituisca il massimo. Calcolare la complessità.

Problemi P/NP/NPC

01/09/2015

Si definiscano le classi P, NP, NPC e si stabilisca, giustificando formalmente la risposta, quale delle seguenti relazioni è ritenuta vera (o verosimile):

12/09/2016

Si definisca formalmente la relazione di riducibilità polinomiale tra problemi decisionali ( ≤P ) e si stabilisca se le seguenti affermazioni sono vere o false:

1) La relazione ≤P è transitiva
2) La relazione ≤P è riflessiva
3) Se ≤P è simmetrica, allora P = NP
4) Se P ≤P Q e Q ∈ P, allora P ∈ P
5) Se P, Q ∈ NPC, allora P ≤P Q se e solo se Q ≤P P

Nel primo caso si fornisca una dimostrazione rigorosa, nel secondo un controesempio.

Nota: in caso di discussioni poco formali l’esercizio non verrà valutato pienamente.