UniTO/anno2/YearI/SecondSem/AC/algoritmi.md

892 lines
45 KiB
Markdown
Raw Normal View History

2018-11-22 13:09:11 +01:00
# 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:
1. Rimozione di simmetrie (i.e. evitare che i cicli vengano percorsi in entrambe le
direzioni nel ciclo hamiltoniano) -> forzare un **ordine**
2. 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:
1. **Branch**: regola che determina il sottoalbero delle scelte da analizzare
2. **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 di** `c`.
* 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:
1. Il numero di nodi nel sottoalbero da generare per ottenere un nodo risposta
2. 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` e `f(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.
1. Greedy: Si deve comunque trovare una regola che porti **vicini** alla soluzione ottima.
2. Pricing: si considera un costo da pagare per ogni vincolo del problema da rinforzare.
3. Programmazione lineare
4. 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:
```
v[j] + OPT(p(j)) >= OPT(j-1) // j e' l'intervallo considerato
```
Questo significa che un intervallo appartiene alla soluzione ottima se il suo peso
migliora la soluzione ottima precedente `OPT(j-1)`. Si nota che in questo caso `p(j)` e'
l'indice dell'ultimo intervallo `i` tale che `i` e `j` 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
```
## 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.
```
dist(v) = min{ dist(u) + w(u,v) | (u,v) in E } // w e' la funzione di peso, dist(s) = 0
```
Se si considerano i nodi da sinistra a destra (vedi ordinamento topologico), quando si ha
un nodo `v` si dispone anche delle informazioni per calcolare `dist(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.
* 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:
1. Un cammino inesistente ha peso infinito.
2. Non esiste cammino lungo `0` archi tra due vertici diversi.
3. Un singolo nodo ha un cammino lungo `0` che porta a se stesso
4. Il cammino di peso minore tra due nodi distinti di lunghezza *al piu'* `m` e' un
cammino di lunghezza minore di `m`, oppure un cammino di lunghezza `m` ottenuto da un
cammino minimo di lunghezza `m-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 vertici `i,j`
sara' scomponibile in due cammini di lunghezza al piu' `m/2`.
Possiamo quindi modificare l'equazione alle ricorrenze:
```
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]
```
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))**
* **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 tra `i,j` di peso minore **che attraversa al piu' i vertici** `1..k` e' un
cammino che attraversa al piu' i vertici `1..k-1` oppure un cammino che attraversa `k`
ottenuto concatenando un cammino minimo tra `i,k` e un cammino minimo tra `k,j`.
```
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
```
Questo algoritmo ha complessita' **O(n^3)**
## 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).
## 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:
1. Se la richiesta non appartiene alla soluzione ottima, `OPT(n) = OPT(n-1)`
2. 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 *tempo*
`W - w[n]`
Si risolve un sottoproblema per ogni insieme di richieste e ogni possibile valore di
tempo restante.
```
OPT(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]) }
```
In questo caso e' necessario confrontare i valori ottenuti includendo ed escludendo il
nodo `i`. L'algoritmo e' chiamato **Subset-Sum** e fa uso di memoizing per i tempi.
* **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)**.
## 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 vettore `v` di valori,
con i quali si considerano i seguenti sottoproblemi.
* **Sottoproblemi**: Se l'i-esimo oggetto non sta nello zaino, allora:
```
Val(i,k) = Val(i-1,k) se i,k != 0 and k < p[i] // k e' la capacita' attuale
```
Se invece entra nello zaino:
```
Val(i,k) = max{ Val(i-1,k), Val(i-1, k-p[i]) + v[i] } se i,k != 0 and k >= p[i]
```
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.
* **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'oggetto `i`, allora lo zaino di dimensione `k-p[i]` e' riempito in modo
ottimo con gli oggetti `1..(i-1)`.
* **Complessita'**: **O(nC)**
* 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:
1. **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'.
2. **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.
* 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.
```
Val(k) = max{ Val(k-1), max{ Val(k-p[i]) + v[i] | p[i] <= k } } se k != 0
```
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.
* **Complessita'**: **O(nC)**
* 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 funzione `P(n)` indica il **numero
di parentesizzazioni** per `A1*A2*..An`. Si nota che l'ultima moltiplicazione puo'
occorrere per `n-1` posizioni diverse. Fissiamo quindi l'indice `k` dell'ultima
moltiplicazione, per considerare `P(k)` parentesizzazioni fino ad `Ak` e `P(n-k)`
parentesizzazioni per `A(k+1)..An`. Si ottiene:
```
P(n) = sum for k from 1 to n-1 of: P(k)*P(n-k) // definizione di numeri catalani
```
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.
* **Sottoproblemi**: Se si considera una **sottostruttura (parentesizzazione) ottima** del
prodotto tra `Ai*..*Aj`, allora `A[i..k]` e `A[k+1..j]` sono parentesizzazioni ottime dei
rispettivi prodotti (`Ai*..*Ak` e `A(k+1)*..*Aj`). Questo puo' essere dimostrato per
assurdo. Si puo' quindi definire il numero minimo di prodotti scalari:
```
m(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.
```
Dato che il valore di `k` non e' noto a prescindere ma si sa che e' compreso tra `i,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:
```
binomial(n, 2) + n = O(n^2) // binomial.. sono i sottoproblemi con i != j
n sono i sottoproblemi con i == j
```
L'algoritmo risultante (*Matrix-chain-order*) ha complessita' **O(n^3)**.
* **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.
## 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**):
1. l'ultima lettera della stringa `a`, ovvero `a[n]`, e' successiva all'ultima lettera
della stringa `b`, ovvero `b[m]`. Costo: `C[a[1..n-1]] + C[b[1..m]] + delta`.
2. Il contrario di `1`, ossia `b[m] successiva ad a[n]`.
Costo: `C[a[1..n]] + C[b[1..m-1]] + delta`
3. `a[n],b[m]` coincidono. Costo: `C[a[1..n-1]] + C[b[1..m-1]] + alpha[n,m]` dove alpha
e' `0` se `a[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]
```
* **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:
1. Caratteri corrispondenti nella stringa e nel testo possono essere diversi.
2. Uno/piu' caratteri della stringa non compaiono nel testo.
3. 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.
## 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 vertice `i` al vertice `1` che attraversa una sola volta i
vertici in `S`. 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 set `S` (a cui viene poi aggiunto all'iterazione successiva):
```
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.
```
La soluzione finale sara' quindi:
```
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` a `1` in `S` (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 peggiore `O(n!)`.
La complessita' nello spazio delle strutture dati e' critica nel caso della prog.din:
`O(n*2^n)`.
* 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 che `C(X)` per la soluzione parziale dalla
radice al vertice X e' dato almeno da:
```
C(X) = sum for j in [1..(i-1)] of: d(S[j], S[j+1])
```
Sappiamo che ogni cammino ciclico che inizia con S[1..i] non puo' avere peso minore di:
```
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 a `n` tanto piu'
l'algoritmo e' inefficiente. Sono necessarie funzioni di bounding:
* **Bounding**: Per potare l'albero delle scelte, si eliminano i seguenti sottoalberi:
1. Quelli in cui sono stati inseriti piu' di `c` elementi
2. 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:
1. Se escludere l'elemento i-esimo non preclude la possibilita' di ottenere una
soluzione, allora si visita il sottoalbero destro
2. 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))
```
## 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:
```
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 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:
```
Sum for k in [1..i] of W[k]*X[k] + W[i+1] <= M
```
Questo assicura che non si considero sottoalberi che superino M.
## 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
```
## 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 anche `T*`,
ovvero il *migliore makespan possibile*. Per trovarlo, si cerca un limite inferiore alla
soluzione ottima, imponendo che ogni macchina svolga almeno `1/m` del lavoro totale.
Questo limite, aggiunto alla condizione che `T* >= 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]`
```
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])
```
Questa sommatoria equivale al **carico totale dovuto alle attivita'
`Sum of T[k] = Sum of t[j]`
Pertanto, per la prima funzione di lower bound:
```
T[i] - t[j] <= 1/m * (sum for j of t[j]) <= T*
```
Considerando la parte restante del carico su `M[i]`, ovvero quello finale `t[j]`,
utilizziamo la seconda funzione di lower bound:
```
t[j] <= T*
```
Unendo le due disuguaglianze:
```
T[i] = (T[i] - t[j]) + t[j] <= 2T*
```
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.
## 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 cui `C` 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 ottima `C*` 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:
```
|C| = Sum for j in U of p[j]
```
Da cui il costo dell'elemento `j` coperto da `S[i]` e':
```
p[j] = 1 / |S[i] - (S[1] U S[2] ... U S[i-1])|
```
La soluzione ottima comprendera' quindi tutti gli elementi di U almeno una volta.
```
|C| = Sum for j in U of p[j] <= Sum for S in C* of (Sum for j in S of p[j])
```
Essendo che uno stesso elemento in U puo' appartenere a piu' di un set in `C*`.
Se S e' un qualsiasi elemento di F, il peso introdotto dalla soluzione C dell'algoritmo
soddisfa:
```
Sum for j in S of p[j] <= H(|S|) // H e' la serie armonica arrestata ad |S|
```
*(vedi slides per catena disuguaglianze)*
Rapporto finale: `H(max{ |S|: S in F }) = H(S*)`. Questo tempo di approssimazione non e'
migliorabile in tempo polinomiale, a meno che P = NP.
* 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 in `i` e' al massimo il
costo del vertice stesso.
```
Sum for e=(i,j) of p[e] <= w[i] // Se la somma e' == w[i], il nodo e' tight
```
Per ogni copertura di vertici S con prezzi positivi e fair, possiamo dire:
```
Sum for e in E of p[e] <= Sum for i in S of (Sum for e=(i,j) of p(e)) <= W(S)
```
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.
* **Rapporto limite**: L'insieme dell'algoritmo e' una copertura di vertici per cui:
```
Sum for e in E of p[e] <= W(S)
```
Questo vale anche per la copertura di costo minimo `S*`.
Se consideriamo che tutti i nodi in S sono **tight**, ovvero la sommatoria e' esattamente
`w[i]` per ogni nodo `i` in S e ogni arco puo' pagare al massimo due volte:
```
W(S) = Sum for i in S of (Sum for e=(i,j) of p[e]) <= 2 * (Sum for e in E of p[e])
```
Si ottiene che `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*)