# 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= 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*)