UniTO/anno2/YearI/SecondSem/AC/algoritmi.md
Francesco Mecca 5e286062f8 MCAD 2019
2018-11-22 13:09:11 +01:00

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:
    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.

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:
      3. Se escludere l'elemento i-esimo non preclude la possibilita' di ottenere una soluzione, allora si visita il sottoalbero destro
      4. 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)