45 KiB
Algoritmi
Elenco dei principali algoritmi studiati a lezione, usando tecniche di programmazione dinamica, branch and bound, algoritmi approssimati e algoritmi probabilistici.
Note sulla Programmazione dinamica
La programmazione dinamica e' applicabile in casi in cui:
- Il numero di sottoproblemi e' polinomiale
- La soluzione del problema puo' essere facilmente calcolata dalle soluzioni ai sottoproblemi
- Vi e' un ordinamento naturale di sottoproblemi dal piu' piccolo al piu' grande con sottoproblemi facili da calcolare.
Caratteristiche
- Iterativa nell'implementazione, ricorsiva nell'espressione dell'equazione alle ricorrenze
- Bottom-up
Note su Backtracking / Branch and Bound
Backtracking
Il backtracking e' una tecnica che permette la wicerca esaustiva (brute-force) esplorando sistematicamente lo spazio degli stati. A differenza della semplice ricerca esaustiva (che prova tutte le possibilita'), il backtracking riduce il numero di tentativi effettuati utilizzando un vettore componente e delle funzioni criterio (bounding functions) per verificare se una soluzione parziale puo' generare una soluzione accettabile.
Caratteristiche
- Si applica a molti problemi che richiedono un insieme di soluzioni che soddisfino una serie di vincoli. I vincoli espliciti identificano lo spazio delle soluzioni.
- Ricorsiva nell'implementazione, per generare un albero degli stati.
- Caratterizzata da una chiamata che esplora l'albero degli stati ricorsivamente resettando lo stato precedente quando ritorna, di modo da considerare tutte le possibili soluzioni.
- Problema: la ricerca esaustiva spesso esplora stati ridondanti. Per questo si ricorre a
soluzioni di potatura dell'albero delle soluzioni, tra cui:
- Rimozione di simmetrie (i.e. evitare che i cicli vengano percorsi in entrambe le direzioni nel ciclo hamiltoniano) -> forzare un ordine
- Individuare soluzioni poco promettenti (che certamente non portano a una soluzione) Le funzioni di bounding sono utilizzate in questo contesto per ridurre lo spazio degli stati.
Calcolo dell'efficienza
L'efficienza di un algoritmo backtracking dipende da:
- Il tempo per generare il valore successivo nel vettore soluzioni
s
. - Il numero di valori di
s
che soddisfano i vincoli espliciti. - Il tempo per il calcolo delle funzioni di bounding.
- Il numero di valori in
s
che soddisfano le funzioni di bounding.
Si nota che due diverse istanze dello stesso problema possono portare lo stesso algoritmo di backtracking a una soluzione lineare O(n) e a una soluzione esponenziale / fattoriale. Questo perche' la generazione dello spazio degli stati in alcuni problemi significa generare tutte le combinazioni / permutazioni di un certo input, ottenendo complessita' elevate. Per questo si utilizzano funzioni di bounding, che riducono tramite dei vincoli il problema.
Teorema
Se definiamo:
- h: altezza albero delle soluzioni
- O(f(n)): tempo richiesto dalla visita di un nodo interno all'albero delle soluzioni
- O(g(n)): tempo richiesto dalla visita di una foglia dell'albero (soluzione)
- D(n): numero di soluzioni
Possiamo dire che visitare solo nodi nei cui sottoalberi e' presente una soluzione richiede tempo:
O(h*f(n)*D(n) + g(n)*D(n))
Nota: questo teorema NON FUNZIONA se la funzione di bounding non assicura che un nodo venga visitato solo se il sottoalbero contiene soluzioni. vedi Ciclo Hamiltoniano.
Branch and Bound
Generalizzazione del backtracking ai problemi di ottimizzazione. Tecnica che permette di calcolare i limiti per le soluzioni parziali al fine di contenere il numero di soluzioni complessive che derivano dall'esplorazione completa dell'albero delle soluzioni. Si divide in due parti:
- Branch: regola che determina il sottoalbero delle scelte da analizzare
- Bound: regola che determina il lower bound per ogni nodo dell'albero, generando un minimo sotto al quale i costi delle soluzioni non scendono. Questo permette di escludere quelle che di sicuro portano a una soluzione peggiore dell'attuale.
Caratteristiche
- Come backtracking esplora tutto l'albero delle soluzioni, ma si applica a problemi di minimizzazione
- Mantiene sempre la migliore soluzione ammissibile tra quelle esaminate
- Non genera i sottoalberi di scelte per i nodi che non possono migliorare la soluzione ottima attuale.
- Tecnica ulteriore di potatura: Se si considera un percorso di costo
c
, non ha senso continuare la ricerca su percorsi con costo maggiore dic
. - Come per il backtracking, il caso peggiore risulta avere complessita' nel tempo super-polinomiale.
Lower bound
Il calcolo del lower bound dipende dalla sequenza di scelte fatte e deve garantire che tutte
le soluzioni ammissibili generabili da quella sequenza abbiano un costo maggiore a LB(x)
.
Si assegna un rango ai nodi sulla base dello sforzo computazionale aggiuntivo per raggiungere dal nodo considerato il nodo risposta. Questo significa considerare:
- Il numero di nodi nel sottoalbero da generare per ottenere un nodo risposta
- Il numero di livelli per cui un nodo risposta dista dal nodo
Queste sono funzioni di costo ideali, nel senso che per determinare il rango esatto con queste funzioni, e' necessario esplorare l'intero sottoalbero. Dato che questo vanificherebbe lo scopo del bounding, si utilizzano ranghi basati su una stima del costo.
Costo di un nodo X
:
C[s](X) = f(h(X)) + g[s](X)
Dove:
- f() e' una funzione non decrescente
- h(X) e' il costo per raggiungere
X
dalla radice g[s](X)
e' il costo stimato necessario a raggiungere un nodo risposta da X.
Least-Cost Search
La strategia di ricerca (FIFO / LIFO) si chiama Least Cost Search, perche' come nodo
successivo viene scelto quello vivo con C[s]
minore.
- FIFO (Breadth-first): LC-s con
g[s](X) = 0
ef(h(X)) = level(X)
- LIFO (Depth-first): LC-s con
f(h(X)) = 0
e per ogni Y figlio di X,g[s](X) >= g[s](Y)
(dato che Y e' piu' vicino a un nodo risposta rispetto a X)
Algoritmi di Approssimazione
Se garantire l'ottimalita' di una soluzione per un problema NP-completo richiede un tempo di calcolo troppo elevato, si ricorre ad una soluzione sub-ottima calcolabile in tempo polinomiale. Per questi casi si usano algoritmi di approssimazione che esprimono la distanza tra soluzione teoricamente ottima e approssimata con un rapporto limite.
Rapporto Limite
Se I
e' un problema di ottimizzazione, allora S(I)
e' l'insieme di soluzioni ammissibili:
- Ad ogni soluzione ammissibile S di I, si associa un costo C(I,S) che per i problemi di minimizzazione sara' il minimo tra tutte le soluzioni ammissibili, viceversa per i problemi di massimo.
Definiamo quindi il rapporto limite come la massima distanza tra il costo di una soluzione prodotta dall'algoritmo approssimato e una prodotta dall'algoritmo ottimale. (un po' come il competitive ratio)
Per gli algortmi approssimati, il rapporto limite e' sempre maggiore di 1. Piu' alto e' il rapporto, peggiore e' la soluzione approssimata.
Schemi di approssimazione
Tecniche che garantiscono di poter calcolare (e contenere entro un certo valore) il rapporto limite.
- Greedy: Si deve comunque trovare una regola che porti vicini alla soluzione ottima.
- Pricing: si considera un costo da pagare per ogni vincolo del problema da rinforzare.
- Programmazione lineare
- Programmazione dinamica su una versione arrotondata dei dati in input.
Appossimazione e Riduzione polinomiale
La teoria della complessita' dice che e' possibile ridurre un problema a un altro in NP, (si pensi a Cook-Levin). Per i problemi di approssimazione bisogna pero' considerare che alcune volte non e' possibile ridurre un algoritmo approssimato ad un altro. Questo perche' i fattori di approssimazione possono cambiare tra un problema e l'altro.
Scheduling di intervalli
In versione pesata e non pesata.
-
Greedy (intro pr.din 1) solo non pesati: con gli intervalli pesati, l'algoritmo greedy non funziona. Funzionamento: si riordinano gli intervalli per tempo di fine crescente. Da questo set ordinato si prendono uno alla volta gli intervalli e li si inserisce all'interno della soluzione. In questo la complessita' massima e' O(nlogn) a causa dell'ordinamento.
-
Dinamica (intro pr.din 1) - intervalli pesati: utilizzato per introdurre il paradigma di programmazione dinamica, l'equazione alle ricorrenze e il memoizing. Si utilizzano intervalli con peso numerico e si vuole massimizzare la somma dei pesi degli intervalli selezionati tra i compatibili (non sovrapposti).
- Sottoproblemi: i sottoproblemi sono soluzioni ottime in un subset degli intervalli.
Definiamo un intervallo come appartenente a una soluzione ottima su un set di intervalli
se soddisfa l'equazione alle ricorrenze:
Questo significa che un intervallo appartiene alla soluzione ottima se il suo peso migliora la soluzione ottima precedentev[j] + OPT(p(j)) >= OPT(j-1) // j e' l'intervallo considerato
OPT(j-1)
. Si nota che in questo casop(j)
e' l'indice dell'ultimo intervalloi
tale chei
ej
sono disgiunti. - Complessita': L'algoritmo richiede tempo esponenziale nel caso peggiore (intervalli ordinati in modo decrescente per tempo di fine, slides). Questo e' dovuto al fatto che calcolare i sottoproblemi ricorsivamente genera un albero di sottoproblemi ridondante.
- Memoizing: usato per risolvere il problema della ridondanza. Si utilizza un array M
che viene riempito iterativamente memorizzando i pesi ottimali. Questo significa che i
valori precedenti (OPT(j-1) in particolare) non vengono ricalcolati ma semplicemente
letti dall'array (accesso O(1) in quanto si conosce l'indice). Cosi' facendo, si ottiene
la soluzione ottima in un tempo O(n).
Memoizing:
M[j] <-- max(v[j] + M[p(j)], M[j-1] //si nota come p(j) restituisca l'indice dell'elemento compatibile
- Sottoproblemi: i sottoproblemi sono soluzioni ottime in un subset degli intervalli.
Definiamo un intervallo come appartenente a una soluzione ottima su un set di intervalli
se soddisfa l'equazione alle ricorrenze:
Cammini minimi in un DAG / grafo con cicli positivi
-
Dinamica (intro pr.din 2): questo algoritmo e' importante perche' illustra la strategia di programmazione dinamica quando si presentano scelte multiple: ad ogni passo non abbiamo piu' due possibilita' ma un numero polinomiale di esse. Si consideri un grafo orientato, aciclico, pesato in cui si vogliono trovare i cammini di lunghezza minima dato un nodo di origine.
- Sottoproblemi: Per ogni nodo
v
, la distanza minima dal nodo di origine sara' il minimo tra le distanze minime dall'origine dei suoi predecessori.
Se si considerano i nodi da sinistra a destra (vedi ordinamento topologico), quando si ha un nododist(v) = min{ dist(u) + w(u,v) | (u,v) in E } // w e' la funzione di peso, dist(s) = 0
v
si dispone anche delle informazioni per calcolaredist(v)
, essendo che per i predecessori la distanza minima e' gia' stata calcolata. - Complessita': la complessita' nel caso peggiore e' data dalla somma della
complessita' dell'ordinamento topologico e dal numero di confronti per calcolare le
distanze. Si ottiene:
O(V+E) + O(V^2) --> O(V^2)
- Note: in questo caso non si usa la struttura dati del DAG, che rimane implicita: i nodi del dag sono sottoproblemi e gli archi le dipendenze da essi.
- Sottoproblemi: Per ogni nodo
-
Dinamica (cammini minimi e prodotto di matrici): Variante. Trovare il peso dei cammini di peso minimo tra tutte le coppie di vertici di un grafo orientato e pesato, supponendo che non ci siano cicli di peso negativo. In questo caso i cicli positivi sono permessi.
- Sottoproblemi: si definiscono i cammini minimi in funzione della lunghezza dei
cammini, sapendo che:
- Un cammino inesistente ha peso infinito.
- Non esiste cammino lungo
0
archi tra due vertici diversi. - Un singolo nodo ha un cammino lungo
0
che porta a se stesso - Il cammino di peso minore tra due nodi distinti di lunghezza al piu'
m
e' un cammino di lunghezza minore dim
, oppure un cammino di lunghezzam
ottenuto da un cammino minimo di lunghezzam-1
fino a un vertice predecessore, seguito dall'arco che collega il predecessore al nodo destinazione.
D(0)[i,j] = 0 se i == j OPPURE infinito se i != j D(m)[i,j] = min{ D(m-1)[i,k] + W[k,j] } for k in 1..n D(1) = W
- Memoizing: Si utilizza una matrice di vertici x vertici (quadrata) in cui si memorizzano i cammini di lunghezza minima da ogni vertice a ogni altro.
- Complessita': O(n^3) dato che dobbiamo iterare su ogni cella della matrice e per ognuna cercare il minimo cammino (O(n)) Se si vuole mostrare tutte le coppie e' necessaria un'ulteriore iterazione, ottenendo complessita O(n^4)
- Variante Efficiente: Se
m
e' pari, un cammino minimo tra due verticii,j
sara' scomponibile in due cammini di lunghezza al piu'm/2
. Possiamo quindi modificare l'equazione alle ricorrenze:
La procedura viene impostata in forma iterativa con l'algoritmo visto in precedenza, solo che la divisione dell'intervallo abbassa la complessita': l'algoritmo e' O(n^3*log2(n))D(m)[i,j] = min{ D(m/2)[i,k] + D(m/2)[k,j] } per m > 1 D(1)[i,j] = W[i,j]
- Floyd-Warshall: Utilizza la numerazione dei vertici tra
1..n
e definisce il cammino tra due vertici in funzione dei vertici attraversati dal cammino. In questo modo il cammino trai,j
di peso minore che attraversa al piu' i vertici1..k
e' un cammino che attraversa al piu' i vertici1..k-1
oppure un cammino che attraversak
ottenuto concatenando un cammino minimo trai,k
e un cammino minimo trak,j
.
Questo algoritmo ha complessita' O(n^3)D(m)[i,j] = W[i,j] se m == 0 OPPURE D(m)[i,j] = min{ D(m-1)[i,j], D(m-1)[i,m] + D(m-1)[m,j] } se m > 0
- Sottoproblemi: si definiscono i cammini minimi in funzione della lunghezza dei
cammini, sapendo che:
Lunghezza massima di una sottosequenza crescente
- Dinamica (intro pr.din. 2): Data una sequenza di numeri, determinare la sottosequenza
crescente piu' lunga al suo interno.
Questo problema puo' essere rappresentato da un grafo che associ ad ogni vertice un
elemento della sequenza, utilizzando un arco orientato per indicare se un elemento ne
precede un altro all'interno della sequenza. Si riconduce quindi il problema al DAG, in cui
si deve trovare il cammino massimo.
- Sottoproblemi: definendo il massimo su un insieme vuoto come
0
:
L(j) = max{ L(i) | i<j && X[i] < X[j]} + 1 // aggiungiamo 1 perche' si inserisce un elemento alla volta
- Complessita': L'algoritmo ricorsivo genera un albero di sottoproblemi ridondante con numero di nodi esponenziale in n. Per questo si adotta l'algoritmo iterativo che si avvale del memoizing (specialmente nel caso in cui si voglia salvare anche la sottosequenza e non solo la sua lunghezza).
- Sottoproblemi: definendo il massimo su un insieme vuoto come
Scheduling (variante di scheduling di intervalli)
Si consideri il problema in cui le richieste in uso di una risorsa sono specificate da una durata e debbano essere soddisfatte entro un particolare intervallo di tempo.
- Dinamica (pr-dinamica-zaino): In questo problema si mostra come l'insieme ovvio dei
sottoproblemi alle volte non sia sufficiente e si debba creare una collezione di
sottoproblemi piu' ricca.
- Sottoproblemi: Come nel caso precedente, due casi:
- Se la richiesta non appartiene alla soluzione ottima,
OPT(n) = OPT(n-1)
- Se la richiesta appartiene alla soluzione ottima, vogliamo trovare il miglior valore
tra tutte le soluzioni che la contengono come ultima richiesta.
Sono necessari piu' sottoproblemi che negli intervalli pesati semplici,
dato che si vuole conoscere la miglior soluzione nelle
n-1
richieste con tempoW - w[n]
Si risolve un sottoproblema per ogni insieme di richieste e ogni possibile valore di tempo restante.
In questo caso e' necessario confrontare i valori ottenuti includendo ed escludendo il nodoOPT(i,w) = max_S{ sum in j of w[j] for j in S} quindi: if (w < w[i]) { OPT(i,w) = OPT(i-1,w) else OPT(i,w) = max{ OPT(i-1, w), w[i] + OPT(i-1, w-w[i]) }
i
. L'algoritmo e' chiamato Subset-Sum e fa uso di memoizing per i tempi. - Se la richiesta non appartiene alla soluzione ottima,
- Complessita': Subset-sum ha complessita' O(nW) per calcolare il valore ottimo, ma data una tabella di valori ottimi dei sottoproblemi, questo si riduce a O(n).
- Sottoproblemi: Come nel caso precedente, due casi:
Zaino 0/1
Knapsack problem classico con uno zaino di capacita' C
e n
oggetti O[1..n]
. Dato ogni
oggetto con un peso e un valore, si deve selezionare un sottoinsieme degli oggetti da
inserire nello zaino per massimizzare il valore del contenuto. Molto simile a scheduling.
-
Dinamica (pr-dinamica-zaino): Si suppone un vettore
p
di pesi e un vettorev
di valori, con i quali si considerano i seguenti sottoproblemi.- Sottoproblemi: Se l'i-esimo oggetto non sta nello zaino, allora:
Se invece entra nello zaino:Val(i,k) = Val(i-1,k) se i,k != 0 and k < p[i] // k e' la capacita' attuale
Anche qui, come nello scheduling, e' necessario confrontare i valori ottenuti includendo ed escludendo l'oggetto considerato. Questo e' dovuto al fatto che anche se un oggetto entra nello zaino, non e' per forza la soluzione ottima assoluta del problema.Val(i,k) = max{ Val(i-1,k), Val(i-1, k-p[i]) + v[i] } se i,k != 0 and k >= p[i]
- Memoizing: in questo caso si utilizza una matrice di indici/capacita' per salvare i valori corrispondenti.
- Correttezza: sappiamo che la definizione data e' corretta perche' applica una
proprieta' essenziale, ovvero che se uno zaino di dimensione
k
e' riempito nel modo ottimo con l'oggettoi
, allora lo zaino di dimensionek-p[i]
e' riempito in modo ottimo con gli oggetti1..(i-1)
. - Complessita': O(nC)
- Sottoproblemi: Se l'i-esimo oggetto non sta nello zaino, allora:
-
Backtracking (3.Zaino-0-1...): Si utilizza un vettore soluzione e un adiacente vettore
Sol
per salvare la soluzione migliore attualmente trovata.- Bounding: Si ipotizza un vettore peso e un vettore valore, entrambi ordinati in
modo non crescente. La funzione di bound determina un upper bound sulla migliore
soluzione ottenibilie in base ai vincoli:
- inserire un oggetto nello zaino significa potare i sottoalberi sinistri: si richiede che il peso degli oggetti nello zaino sommato a quello dell'oggetto considerato non superi la capacita'.
- scartare un oggetto dallo zaino significa potare i sottoalberi destri: si cerca un limite superiore al valore raggiungibile senza considerare l'oggetto stesso. Si nota come questo algoritmo sia una ricerca in profondita' con vincoli aggiunti.
- Bounding: Si ipotizza un vettore peso e un vettore valore, entrambi ordinati in
modo non crescente. La funzione di bound determina un upper bound sulla migliore
soluzione ottenibilie in base ai vincoli:
-
Branch and bound (4.BB.2.1.pdf): Utilizzato per il confronto con l'algoritmo di backtracking (sopra). Si mantiene l'ipotesi dei vettori ordinati in maniera non crescente. Si deve pero' impostare il problema come un problema di minimo.
- Funzione obbiettivo da minimizzare:
(minus) sum for i in [1,n] of: V[i]*S[i] = C(X) // somma negativa dei valori corrispondenti agli elementi della soluzione. // si utilizza come funzione di costo per tutti i nodi foglia Con vincolo: sum for i in [1,n] of: P[i]*S[i] <= C // la somma dei pesi degli elementi della soluzione non deve eccedere la capacita'
- Quindi e' possibile definire il costo come la funzione obbiettivo nel caso in cui la soluzione sia accettabile (somma dei pesi minore di capacita'), mentre il costo e' infinito se il nodo non appartiene a una soluzione accettabile. Funzione di costo per i nodi interni:
C(X) = min{ C(left(X)), C(right(X)) }
- Questo e' il lower bound di C(X) per ogni nodo X, per cui puo' essere usata come
funzione di lower bound
C[S](X)
- L'algoritmo utilizza la tecnica greedy per migliorare il valore attuale della soluzione provvisoria. (sceglie l'elemento migliore dal punto di vista del valore).
- La funzione di upper bound puo' essere trovata considerando che se si riempie lo zaino
alla cieca si ottiene il risultato peggiore. L'alternativa semplice e' utilizzare la
funzione obbiettivo su ogni nodo a livello
j
. - Confronto con backtracking
Zaino con Ripetizione
Estensione dello zaino 0/1 in cui sono permesse ripetizioni di elementi. Il problema si semplifica in quanto non serve l'informazione sugli oggetti usati. Aumenta pero' il numero di scelte (da due -> scheduling a multiple)
-
Dinamica (pr-dinamica-zaino): Si utilizzano sottoproblemi dipendenti unicamente dalla capacita' dello zaino.
- Sottoproblemi: A differenza dello zaino 0/1, ora non consideriamo piu' il caso
k < p[i]
perche' abbiamo potenzialmente un numero infinito di oggetti.
Per ogni oggetto, dobbiamo contollare il massimo valore ottenibile con gli oggetti a disposizione, e poi controllare di aver migliorato la soluzione precendente, ossia quella in cui la capacita' era ridotta di 1. Si nota come l'algoritmo iteri sulle capacita' di modo da calcolare i casi migliori con le capacita' minori e procedere con memoizing.Val(k) = max{ Val(k-1), max{ Val(k-p[i]) + v[i] | p[i] <= k } } se k != 0
- Complessita': O(nC)
- Sottoproblemi: A differenza dello zaino 0/1, ora non consideriamo piu' il caso
-
Greedy (pr-dinamica-zaino), oggetti continui: in questo caso riempiamo lo zaino partendo dagli elementi piu' preziosi. Solo algoritmo.
Moltiplicazione di matrici
Date n
matrici, determinare una parentesizzazione del prodotto A1*A2*..An che
minimizzi il numero di moltiplicazioni scalari.
- Dinamica (Cammini minimi e prodotto di matrici): Si comincia considerando quante e quali
sono le possibili parentesizzazioni di
n
matrici. La funzioneP(n)
indica il numero di parentesizzazioni perA1*A2*..An
. Si nota che l'ultima moltiplicazione puo' occorrere pern-1
posizioni diverse. Fissiamo quindi l'indicek
dell'ultima moltiplicazione, per considerareP(k)
parentesizzazioni fino adAk
eP(n-k)
parentesizzazioni perA(k+1)..An
. Si ottiene:
Possiamo quindi dire che P(n) ha lower bound Ω(2^n). L'approccio brute-force risulta quindi troppo costoso. Si cerca di ricavare dei sottoproblemi per applicare la pr. din.P(n) = sum for k from 1 to n-1 of: P(k)*P(n-k) // definizione di numeri catalani
- Sottoproblemi: Se si considera una sottostruttura (parentesizzazione) ottima del
prodotto tra
Ai*..*Aj
, alloraA[i..k]
eA[k+1..j]
sono parentesizzazioni ottime dei rispettivi prodotti (Ai*..*Ak
eA(k+1)*..*Aj
). Questo puo' essere dimostrato per assurdo. Si puo' quindi definire il numero minimo di prodotti scalari:
Dato che il valore dim(i,j) = m(i,k) + m(k+1,j) + c[i-1]*c[k]*c[j] // dove c.. sono il numero di prodotti per moltiplicare le rispettive matrici.
k
non e' noto a prescindere ma si sa che e' compreso trai,j-1
, allora li si prova tutti:m(i,j) = min{ m(i,k) + m(k+1,j) + c[i-1]*c[k]*c[j] } per k in [i,j]
- Complessita': se si utilizza l'approccio top-down, si ha complessita' Ω(2^n).
Dato che l'albero delle soluzioni calcolato a brute-force risulta in molte computazioni
ridondanti, si applica la pr. dinamica. Questo permette di ridurre il numero di problemi
calcolati secondo la formula:
L'algoritmo risultante (Matrix-chain-order) ha complessita' O(n^3).binomial(n, 2) + n = O(n^2) // binomial.. sono i sottoproblemi con i != j n sono i sottoproblemi con i == j
- Memoizing: Il calcolo bottom-up richiede una matrice nxn per ricordare i valori dei sottoproblemi risolti. Il prodotto viene effettuato sempre tra una matrice di indice inferiore e una di indice superiore, pertanto serve unicamente il triangolo superiore della matrice.
- Memorizzare la sequenza di moltiplicazioni: si utilizza un algoritmo ricorsivo che
utilizza la matrice soluzione per memorizzare la posizione
k
della parentesizzazione.
- Sottoproblemi: Se si considera una sottostruttura (parentesizzazione) ottima del
prodotto tra
Distanza di matching (edit)
Misurare la distanza tra due stringhe. Uno dei modi piu' semplici e' allinearle e controllare lettera per lettera se un carattere e' match oppure no. Il valore associato all' allineamento (costo) e' il numero di operazioni necessarie per allineare le stringhe.
- Dinamica (distanza di edit): la distanza di edit si presenta quindi come il costo del
miglior allineamento. Essendo che controllare tutti i possibili allineamenti e' un
problema combinatorio (esponenziale in tempo), possiamo definire dei sottoproblemi per la
pr.din.
- Sottoproblemi: date due stringhe, si considerino due caratteri
a[i],b[j]
. Per allinearle ci sono 3 opzioni (nell'allineamento migliore):- l'ultima lettera della stringa
a
, ovveroa[n]
, e' successiva all'ultima lettera della stringab
, ovverob[m]
. Costo:C[a[1..n-1]] + C[b[1..m]] + delta
. - Il contrario di
1
, ossiab[m] successiva ad a[n]
. Costo:C[a[1..n]] + C[b[1..m-1]] + delta
a[n],b[m]
coincidono. Costo:C[a[1..n-1]] + C[b[1..m-1]] + alpha[n,m]
dove alpha e'0
sea[n] == b[m]
Questo porta alla seguente definizione di equazione alle ricorrenze:
E(i,j) = min{ delta + E(i-1,j), delta + E(i,j-1), alpha[i,j] + E(i-1,j-1) } dove alpha[i,j] = 0 se a[i] == b[j], altrimenti != 0 se sono diverse. Nota: E(i,j) e' il costo della distanza di edit tra a[1..i] e b[1..j]
- l'ultima lettera della stringa
- Memoizing: si utilizza una matrice (n+1)x(m+1) per memorizzare la distanza di edit
ottimale per ogni sottostringa
a[1..i],b[1..j]
. La risultante tabella puo' essere ripercorsa all'indietro per ottenere un risultato ottimale. (slides) - Complessita': L'algoritmo iterativo ha complessita' O(nm).
- String matching approssimato: se si considera il problema di trovare un'occorrenza di
una data stringa in un testo, ammettendo i seguenti errori:
- Caratteri corrispondenti nella stringa e nel testo possono essere diversi.
- Uno/piu' caratteri della stringa non compaiono nel testo.
- Uno/piu' caratteri del testo non compaiono nella stringa.
Definiamo quindi un occorrenza n-approssimata quella in cui abbiamo un match preciso
a
n
caratteri diversi. (2-approssimato -> 2 caratteri diversi, etc). L'algoritmo risultante e' simile alla distanza di edit, con la stessa complessita' nel tempo.
- Sottoproblemi: date due stringhe, si considerino due caratteri
String Matching (KMP)
Data una stringa e un testo, si vogliono trovare tutte le occorrenze esatte della stringa nel testo. A differenza della distanza di edit / matching approssimato, qui non si considerano errori nel matching.
- Backtracking (KnuthMorrisPratt): il metodo piu' naif consiste nell'allineare i caratteri
di sinistra della stringa e del testo finche non si trova un'occorrenza della stringa / si
trovano due caratteri diversi (che negano l'occorrenza). Si ricomincia quindi con la
stringa fatta avanzare verso destra di un carattere.
- Problema: numero di confronti molto elevato nel caso di match parziali. O(nm). Questo puo' essere ridotto a O(n+m) spostando la stringa di piu' di un carattere in presenza di un errore oppure riducendo i confronti saltando alcune parti dopo lo shift.
- Si nota che solo il pattern aiuta a calcolare l'indice da cui ricominciare il confronto mentre sul testo non e' necessario backtracking.
- Se si tiene da conto il numero di posizioni da attraversare nel caso si incontri un errore, possiamo utilizzare quest'informazione per ridurre il numero di iterazioni necessarie. Questo significa che al momento dell'errore non si ricomincia con la stringa da capo ma si torna all'ultimo carattere che potrebbe dare un match (l'ultimo uguale).
- Si tratta di un automa che effettua transizioni all'indietro per tutti i simboli che non permettono un match del pattern, fino all'ultimo carattere uguale che potrebbe permettere un match.
Commesso Viaggiatore
Il commesso viaggiatore e' un problema che si presenta in questo modo: Data una lista di citta' e distanze tra ogni coppia di citta', qual'e' la strada piu' corta possibile per visitare tutte le citta' e tornare alla citta' di partenza? Si identifica quindi come il problema di trovare cicli di costo minimo a partire da un dato vertice.
Formulazione grafi: Dato un grafo non orientato, completo, e pesato, trovare un ciclo semplice che contenga tutti i vertici del grafo e sia di costo minimo.
-
Dinamica (CommViaggDin): si parte considerando
g(i,S)
, ossia una funzione che ritorna il costo di un cammino minimo dal verticei
al vertice1
che attraversa una sola volta i vertici inS
. Pertanto, il ciclo minimo sara'g(1, V-{1})
, ovvero il cammino minimo che torni al vertice di partenza (da 1 a 1) passando una sola volta da tutti i vertici a parte 1.- Sottoproblemi: per il principio di ottimalita', possiamo definire per ogni
i
non appartenente al setS
(a cui viene poi aggiunto all'iterazione successiva):
La soluzione finale sara' quindi:g(i,S) = min{ d[i,j] + g(j,S-{j}) } con S non vuoto e j in S. Si nota che g(i, S=vuoto) = d[i,1] // la distanza tra i e 1 se non vi sono vertici in mezzo.
g(1,V-{1}) = min{ d[1,k] + g(k,V-{1,k}) } con k in [2..n]
- Memoizing: si utilizza una tabella per memorizzare le lunghezze minime di un cammino
semplice da
i
a1
inS
(su slides gtab). - Complessita': l'algoritmo in programmazione dinamica si basa sul calcolo di
g(i,S)
, il che richiede un gran numero di confronti:O(n^2*2^n)
. Si nota come questa complessita' sia in realta' vantaggiosa rispetto alle implementazioni in branch&bound / backtracking, che permettono di ottenere un caso peggioreO(n!)
. La complessita' nello spazio delle strutture dati e' critica nel caso della prog.din:O(n*2^n)
.
- Sottoproblemi: per il principio di ottimalita', possiamo definire per ogni
-
Branch and Bound (4.BB.2.1): Si comincia definendo il costo dei nodi. La funzione
C(X)
avra' quindi due differenti significati:- Se X e' foglia, allora C(X) e' il costo del cammino ciclico individuato (soluzione)
- Se X e' interno, allora C(X) e' il costo della foglia di costo minimo nel
sottoalbero di X
Si cercano quindi due funzioni per cui
C[s](X) <= C(X) <= u(X)
. C[s](X)
puo' essere definita considerando cheC(X)
per la soluzione parziale dalla radice al vertice X e' dato almeno da:
Sappiamo che ogni cammino ciclico che inizia con S[1..i] non puo' avere peso minore di:C(X) = sum for j in [1..(i-1)] of: d(S[j], S[j+1])
C[s](X) = C(X) + floor( A + B + sum for h not in S of: D[h])/2 // se X non foglia C[s](X) = C(X) + d[S(i), S(1)] // se X e' una foglia - dove: A = min{ d[S[i], h] } con h not in S // l'arco di lunghezza minima da un nodo di S[1..i] a h B = min{ d[S[1], h] } con h not in S // l'arco di lunghezza minima da un vertice h non in S al nodo di origine D[h] = min{ d[h,p] + d[h,q] } con h != p != q, h not in S ma p,q possono appartenere // ogni vertice in h not is S dovra' essere attraversato, quindi estremo di due archi (h,p) e (h,q) A + B + somma per h non in s di: D[h] = peso di 2+2k = 2(k+1) archi // k e' il numero di vertici non in S - A questi archi ne vanno aggiunti ancora k+1 per chiudere il ciclo, ma il loro peso non e' inferiore alla meta' di quello dei 2(k+1) archi Quindi C[s](X) e' definita come sopra in quanto valore minimo per C(X)
Se si utilizza una variabile globale per salvare il costo della soluzione minima, si ottiene l'algoritmo.
Permutazioni
Generare tutte le possibili permutazioni di un dato valore in input, in cui i numeri dispari precedono i numeri pari. Esempio utilizzato per illustrare il principio di backtracking con vincoli.
- Backtracking (2.1.Backtracking): L'algoritmo e' ottenuto verificando che nella prima meta'
del vettore soluzioni devono comparire solo numeri dispari e nella seconda solo numeri
pari. Questo e' il vincolo esplicito che ci permette di ridurre lo spazio delle soluzioni.
- Complessita': Senza vincoli, O(n!), con vincolo pari/dispai invece proporzionale al numero di permutazioni da generare.
Sottoinsiemi
Dati due interi n,c
, generare tutti i sottinsiemi di {1..n}
di cardinalita' c
.
- Backtracking (2.1.Backtracking): La ricerca della soluzione avviene esaminando tutti i
sottinsiemi di
{1..n}
e verificando quali hanno cardinalita'c
. Applicazione diretta della ricerca esaustiva.- Complessita': Il tempo di esecuzione e' O(n2^n), dato che la generazione dello
spazio degli stati richiede tempo esponenziale in
2^n
e il calcolo del numero di elementi al piu'n
. Si nota come gli insiemi da generare sono binomial(n,c) e quindi O(n^c), per cui quanto piu'c
e' piccolo rispetto an
tanto piu' l'algoritmo e' inefficiente. Sono necessarie funzioni di bounding: - Bounding: Per potare l'albero delle scelte, si eliminano i seguenti sottoalberi:
- Quelli in cui sono stati inseriti piu' di
c
elementi - Quelli in cui il numero degli elementi della soluzione parziale piu' il numero di elementi non considerati e' minore di c. Le funzioni di bounding decideranno quindi:
- Se escludere l'elemento i-esimo non preclude la possibilita' di ottenere una soluzione, allora si visita il sottoalbero destro
- Se aggiungere l'elemento i-esimo non genera una soluzione parziale con piu' di
c
elementi, allora si visita il sottoalbero sinisto. Questo significa che ogni nodo viene visitato solo se nel suo sottoalbero e' presente almeno una soluzione. Dal teorema sopra citato, si ottiene costo in tempo:
O(n*n^c) = O(n^(c+1))
- Quelli in cui sono stati inseriti piu' di
- Complessita': Il tempo di esecuzione e' O(n2^n), dato che la generazione dello
spazio degli stati richiede tempo esponenziale in
Cicli Hamiltoniani
Dato un grafo non orientato, esiste un ciclo semplice che contiene tutti i nodi? Nota: semplice => ogni nodo una volta sola.
- Backtracking (1.Backtracking-intro): Si considera un vettore di nodi i cui posti
!=0
indicano che il nodo e' stato visitato, 0 altrimenti. L'algoritmo fa uso delle funzioni di bounding per potare l'albero delle soluzioni da simmetrie (cicli in un senso o nell'altro) ed escludere i percorsi che dividono il grafo di modo che un ciclo semplice non sia piu' possibile.
Somma di sottoinsieme
Dati un insieme e un intero, trovare tutti i sottoinsiemi la cui somma degli elementi sia l'intero.
- Backtracking (2.2.Backtracking): I sottoinsiemi sono rappresentati da un vettore i cui
elementi possono assumere il valore 0 per l'elemento presente e 1 per il valore assente.
- Bounding: la funzione di bounding e' analoga a quella per i sottoinsiemi di
cardinalita' fissa:
Questo assicura che non si considerino sottoalberi che non possono portare a M. Inoltre se si considera vettori in ordine non decrescente si puo' utilizzare la seguente funzione di bounding:B(X[1]..X[i]) = Sum for k in [1..i] of W[k]*X[k] + Sum for k in [i+1..|W|] of W[k] >= M Dove M e' l'intero considerato.
Questo assicura che non si considero sottoalberi che superino M.Sum for k in [1..i] of W[k]*X[k] + W[i+1] <= M
- Bounding: la funzione di bounding e' analoga a quella per i sottoinsiemi di
cardinalita' fissa:
Colorabilita' dei grafi
Dato un grafo trovare tut i i modi in cui e' possibile colorare i vertici di esso in modo che i vertici adiacenti abbiano colori diversi.
- Backtracking (2.2.Backtracking): L'algoritmo utilizza un vettore X per associare colori a
vertici. La procedura prova ad assegnare al nodo i-esimo ogni colore supponendo che siano
tutti compatibili, richiamandosi sul nodo successivo ricorsivamente. I controlli fanno si'
che le combinazioni che non portano a soluzione siano escluse in quanto non vanno a buon
fine. Si nota come in questo algoritmo non siano presenti procedure di reset ma check che
a priori escludono soluzioni (combinazioni di colori) non valide.
- Complessita': La verifica di ogni scelta richiede controlli di complessita'
trascurabile, per cui se il numero di chiamate ricorsive di
Color(i)
e'C(i)
:C(1) = m*C(2) = m*m*C(3) = ... m^n --> O(m^n) // m = numero di colori, n = numero nodi
- Complessita': La verifica di ogni scelta richiede controlli di complessita'
trascurabile, per cui se il numero di chiamate ricorsive di
N Regine
Data una scacchiera NxN, si posizionino su di essa N regine di modo che nessuna di esse sia sotto scacco. (una per riga, una per colonna, una per diagonale).
- Backtracking (2.2.Backtracking): in questo caso il vincolo esplicito e' che nessuna
regina sia sulla stessa riga, mentre il vincolo implicito e' che nessuna sia sotto scacco.
Per questo si ricorre sulle righe utilizzando come insieme delle scelte le colonne 1..N. Si
ricorre unicamente se la posizione
i, X[i]
e' sicura, di modo da generare solo le disposizioni ammesse e stamparle.- Verifica: La parte complessa dell'algoritmo qui risiede nella verifica di una diagonale libera, in quanto le colonne libere sono facili da verificare. Si nota che la differenza degli indici di una diagonale discendente e' costante, mentre la somma degli indici di una diagonale discendente e' anch'essa costante. Questo permette di verificare le colonne. Ci sono formulazioni alternative.
Load Balancing
Dato un insieme finito di programmi, tali che ogni programma richiede un tempo intero di
esecuzione, e m
processori, eseguire tutti i programmi sui processori nel minor tempo
possibile.
- Approssimata/Greedy (algoritmi-di-approssimazione-I): il problema richiede di minimizzare
il massimo carico su ogni macchina (makespan). L'algoritmo piu' semplice considera i
lavori in ordine qualunque e si assegna un lavoro alla volta alla macchina piu' scarica in
quel momento. Non si arriva alla soluzione ottima (greedy), quindi si analizza il makespan.
- rapporto limite: Se chiamiamo
T
il makespan greedy, dobbiamo calcolare ancheT*
, ovvero il migliore makespan possibile. Per trovarlo, si cerca un limite inferiore alla soluzione ottima, imponendo che ogni macchina svolga almeno1/m
del lavoro totale. Questo limite, aggiunto alla condizione cheT* >= max[j] of t[j]
(un lavoro molto piu' grande degli altri). Sappiamo che prima dell'assegnamento di un lavoro, la macchina scelta ha il minor carico tra tutte le macchine. Prima dell'assegnamento:T[i] = T[i] - t[j]
Questa sommatoria equivale al **carico totale dovuto alle attivita'Si sommano tutti i carichi: Sum for k in 1..n of T[k] >= m(T[i] - t[j]) Che significa: T[i] - t[j] <= 1/m * (sum for k of T[k])
Sum of T[k] = Sum of t[j]
Pertanto, per la prima funzione di lower bound:
Considerando la parte restante del carico suT[i] - t[j] <= 1/m * (sum for j of t[j]) <= T*
M[i]
, ovvero quello finalet[j]
, utilizziamo la seconda funzione di lower bound:
Unendo le due disuguaglianze:t[j] <= T*
Che prova un rapporto limite = 2 (2T*/T*). L'algoritmo si avvicina a questo valore al crescere del numero di macchine. Questo puo' essere migliorato considerando i lavori ordinati in ordine decrescente di tempo. Si ottiene un rapporto limite di 3/2. Questo algoritmo ha complessita' O(nlogn) ed e' chiamato Largest Processing Time.T[i] = (T[i] - t[j]) + t[j] <= 2T*
- rapporto limite: Se chiamiamo
Center Selection
Si vogliono selezionare k
centri dove costruire grossi centri commerciali e un insieme di
S
siti. Ogni individuo, residente in un sito, andra' in uno dei k
centri (il piu'
vicino).
Bisogna bilanciare la collocazione dei centi di modo da non sfavorire nessun sito. I siti sono collocati su di un piano e su qualsiasi punto di questo piano e' possibile collocare i centri.
-
Approssimata/Greedy (algoritmi-di-approssimazione-I): Definiamo
C
un r-cover se:dist(s,C) <= r for every s in S
Il massimo valore di
r
per cuiC
e' un r-cover e' il raggio di copertura di C ed e' indicato con r(C). Questo rappresenta il percorso piu' lungo che ciascuno deve fare per giungere al centro commerciale piu' vicino.Bisogna quindi minimizzare r(C).
La soluzione greedy propone di selezionare ogni sito senza considerare dove andranno collocati gli altri, cioe' per ogni centro ottimizzare la distanza tra tutti i siti. Questo pero' risulta in assegnamenti poco efficaci. Si sfrutta allora la conoscenza di
r(C*)
da una soluzione ottimaC*
per costruire una soluzione approssimata con:r(C) <= 2*r(C*)
Nel caso in cui
r(C*)
non sia noto, si puo' procedere per tentativi effettuando una ricerca binaria che peo' aumenta di un fattore logaritmico la complessita'. Questa soluzione costruisce i centri commerciali nei siti stessi e non in punti qualunque del piano. La complessita' e' O(n*k).
Set Cover
Dati un insieme finito U e una famiglia di sottoinsiemi F di U tale che ogni elemento di U appartiene almeno ad un sottoinsieme di F.
Si vuole determinare una collezione di sottoinsiemi di F definita come copertura C se l'unione di tutti gli insiemi in C restituisce U. Si vuole inoltre trovare una collezione C di cardinalita' minima. Il costo di una soluzione ammissibile C e' |C|.
-
Approssimazione/Greedy (algoritmi-di-approssimazione-I): Costruire la copertura un insieme alla volta, scegliendo ad ogni passo l'insieme che sembra migliore, ovvero quello che contiene il massimo numero di elementi non ancora coperti. Questa soluzione ha complessita'
O(|U|*|F|*min{|U|,|F|}
.- Rapporto Limite: Si distribuisce il costo unitario di ogni sottoinsieme della
soluzione C sugli elementi che esso aggiunge alla copertura. (Se un set aggiunge 6
elementi, il peso di ogni elemento sara' 1/6)
Si puo'quindi considerare:
Da cui il costo dell'elemento|C| = Sum for j in U of p[j]
j
coperto daS[i]
e':
La soluzione ottima comprendera' quindi tutti gli elementi di U almeno una volta.p[j] = 1 / |S[i] - (S[1] U S[2] ... U S[i-1])|
Essendo che uno stesso elemento in U puo' appartenere a piu' di un set in|C| = Sum for j in U of p[j] <= Sum for S in C* of (Sum for j in S of p[j])
C*
. Se S e' un qualsiasi elemento di F, il peso introdotto dalla soluzione C dell'algoritmo soddisfa:
(vedi slides per catena disuguaglianze) Rapporto finale:Sum for j in S of p[j] <= H(|S|) // H e' la serie armonica arrestata ad |S|
H(max{ |S|: S in F }) = H(S*)
. Questo tempo di approssimazione non e' migliorabile in tempo polinomiale, a meno che P = NP.
- Rapporto Limite: Si distribuisce il costo unitario di ogni sottoinsieme della
soluzione C sugli elementi che esso aggiunge alla copertura. (Se un set aggiunge 6
elementi, il peso di ogni elemento sara' 1/6)
Si puo'quindi considerare:
-
Approssimazione/Pricing (approssimati II): l'algoritmo greedy per i set pesati puo' anche essere visto come algoritmo di pricing: il costo di ogni sottoinsieme e' un costo di condivisione. Pertanto la definizione di greedy / pricing porta allo stesso problema in questo caso.
Copertura di vertici
Versione pesata. Trovare un insieme di vertici di un grafo che copra tutti gli archi del grafo, con peso minimo.
Il peso puo' essere definito come:
W(S) = Sum for i in S of w[i]
- Approssimati/Pricing (approssimati II): Per ogni vertice selezionato, si coprono tutti gli
archi incidenti in quel vertice. Se carichiamo questi archi di un costo totale maggiore
del costo del vertice, allora la procedura diventa unfair, mentre il prezzo
p[e]
puo' essere definito fair se il costo totale degli archi incidenti ini
e' al massimo il costo del vertice stesso.
Per ogni copertura di vertici S con prezzi positivi e fair, possiamo dire:Sum for e=(i,j) of p[e] <= w[i] // Se la somma e' == w[i], il nodo e' tight
L'algoritmo trova una copertura e attribuisce i costi, usandoli per guidare la selezione dei vertici. Da questo punto di vista l'algoritmo puo' essere considerato greedy.Sum for e in E of p[e] <= Sum for i in S of (Sum for e=(i,j) of p(e)) <= W(S)
- Rapporto limite: L'insieme dell'algoritmo e' una copertura di vertici per cui:
Questo vale anche per la copertura di costo minimoSum for e in E of p[e] <= W(S)
S*
. Se consideriamo che tutti i nodi in S sono tight, ovvero la sommatoria e' esattamentew[i]
per ogni nodoi
in S e ogni arco puo' pagare al massimo due volte:
Si ottiene cheW(S) = Sum for i in S of (Sum for e=(i,j) of p[e]) <= 2 * (Sum for e in E of p[e])
W(S) <= 2 W(S*)
, per cui il rapporto limite dell'algoritmo pricing e' 2.
Cammini disgiunti
Confronto greedy / pricing.
Dato un grafo orientato e un valore intero c
detto capacita' si vuole considerare ciascuna
coppia di nodi come una richiesta di passaggio dal nodo s[i] al nodo t[i] come sorgente /
destinazione. Si vogliono trovare i cammini che permettono di soddisfare il maggior numero
possibile di richieste di passaggio.
Problemi: percorsi che condividono archi non sono ottimali, e troppi cammini che condividono uno stesso arco portano a disfunzioni nella maggior parte delle applicazioni.
Si consideri il caso in cui c==1
, ovvero ogni arco porta solo una richiesta e i cammini
devono per forza essere disgiunti.
- Approssimati/Greedy (algoritmi II): L'algoritmo greedy tende a selezionare cammini piu' brevi rispetto a cammini piu' lunghi. Se il grafo e' connesso, allora l'algoritmo seleziona almeno un cammino. (vedi slides)