⋔ Grafi e ottimizzazione

Programmazione dinamica: sottoproblemi, memoization e tabelle

I principi della programmazione dinamica: sottostruttura ottima, sottoproblemi sovrapposti, memoization contro tabulazione e un metodo in cinque passi.

Quanti modi ci sono di salire una scala di 30 gradini facendo passi da uno o da due gradini alla volta? Una soluzione ricorsiva ingenua si scrive in due righe: i modi per arrivare al gradino n sono i modi per arrivare al gradino n − 1 più quelli per arrivare al gradino n − 2. Eseguita così com'è, però, questa funzione per n = 30 fa più di un milione di chiamate, e per n = 50 non termina in tempi ragionevoli. Con una piccola modifica risponde istantaneamente anche per n = 500.

Quella modifica è l'idea centrale della programmazione dinamica (PD): non ricalcolare mai due volte la stessa cosa. Il nome, introdotto da Richard Bellman negli anni Cinquanta, non ha niente a che fare con la programmazione dei computer in senso moderno né con qualcosa di «dinamico»: indica una tecnica di pianificazione per stadi.

Le due condizioni

La programmazione dinamica funziona quando il problema ha due proprietà.

Sottostruttura ottima: la soluzione ottima del problema si costruisce a partire dalle soluzioni ottime di sottoproblemi più piccoli. Per esempio, se il cammino più breve da Milano a Roma passa per Bologna, il suo tratto da Milano a Bologna è a sua volta il cammino più breve tra queste due città.

Sottoproblemi sovrapposti: gli stessi sottoproblemi compaiono più volte. Nella scala, il numero di modi per arrivare al gradino 10 serve sia per calcolare il gradino 11 sia per il 12. Se i sottoproblemi non si ripetono, come nel merge sort, si parla di «divide et impera» e la PD non porta vantaggi.

Memoization: dall'alto verso il basso

Il primo modo di applicare la PD è tenere la ricorsione naturale e aggiungere una cache dei risultati già calcolati:

from functools import lru_cache

@lru_cache(maxsize=None)
def modi(n):
    if n <= 1:
        return 1
    return modi(n - 1) + modi(n - 2)

Ogni valore di n viene calcolato una volta sola; le chiamate successive leggono il risultato dalla cache. Il costo passa da esponenziale a lineare. Questo approccio si chiama memoization ed è il più facile da ottenere partendo da una ricorsione corretta.

Lo svantaggio è la profondità della ricorsione: per n molto grandi si supera il limite dello stack.

Tabulazione: dal basso verso l'alto

Il secondo modo è riempire una tabella a partire dai casi più piccoli, in un ordine che garantisca che ogni valore necessario sia già pronto:

def modi(n):
    a, b = 1, 1          # modi per i gradini 0 e 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b if n >= 1 else 1

Qui la tabella si riduce a due variabili, perché ogni valore dipende solo dai due precedenti. Questa riduzione della memoria è frequente: se una riga della tabella dipende solo dalla precedente, basta tenerne due.

Aspetto Memoization Tabulazione
Ordine di calcolo deciso dalla ricorsione deciso dal programmatore
Sottoproblemi calcolati solo quelli necessari tutti quelli della tabella
Rischio di stack overflow sì no
Ottimizzazione della memoria difficile spesso semplice

Un metodo in cinque passi

Quando si affronta un problema nuovo conviene seguire sempre lo stesso schema.

  1. Definire lo stato: quale informazione minima descrive un sottoproblema? Per la scala basta il numero del gradino. Per altri problemi servono due o tre indici.
  2. Scrivere la ricorrenza: come si ottiene il valore di uno stato dai valori di stati più piccoli?
  3. Fissare i casi base: i sottoproblemi abbastanza piccoli da conoscere la risposta direttamente.
  4. Scegliere l'ordine di calcolo: gli stati vanno risolti dopo quelli da cui dipendono. In termini di grafi, le dipendenze tra stati formano un DAG, e l'ordine di calcolo è un suo ordinamento topologico.
  5. Ricostruire la soluzione: oltre al valore ottimo, spesso serve la scelta che lo realizza. Si memorizza, per ogni stato, quale opzione ha dato il migliore risultato, e alla fine si ripercorre la catena.

Un esempio di ottimizzazione: il resto con meno monete

Un distributore deve dare un resto di R centesimi usando monete di certi tagli e vuole usarne il meno possibile. Stato: l'importo r. Ricorrenza: monete(r) = 1 + minimo di monete(r − t) per ogni taglio t ≤ r. Caso base: monete(0) = 0.

Con tagli da 1, 3 e 4 e un resto di 6, l'approccio avido prende prima la moneta più grande (4) e poi due da 1, totale tre monete. La PD trova invece 3 + 3, due monete. È un esempio tipico in cui la scelta che sembra migliore localmente non è quella ottima, e la PD, esaminando sistematicamente tutti i sottoproblemi, non si lascia ingannare.

Dove si incontra

La programmazione dinamica è ovunque nell'informatica: confronto tra testi e versioni di file, correttori ortografici, riconoscimento vocale con l'algoritmo di Viterbi, allineamento di sequenze genetiche, cammini minimi come Bellman-Ford e Floyd-Warshall, problemi dello zaino e di taglio, analisi sintattica di grammatiche, pianificazione di scorte e investimenti nel tempo.

In sintesi

  • La PD si applica quando ci sono sottostruttura ottima e sottoproblemi sovrapposti.
  • La memoization aggiunge una cache a una ricorsione già corretta.
  • La tabulazione riempie una tabella dal basso ed evita i limiti della ricorsione.
  • Stato, ricorrenza, casi base, ordine e ricostruzione sono i cinque passi da seguire.
  • Dove le scelte avide falliscono, la PD esamina tutte le alternative senza ripetere calcoli.
MM
Marco Marzaduri

Marco Marzaduri si occupa di cybersecurity, informatica forense e sviluppo web. Laureato in informatica, unisce il rigore dell'analisi tecnica all'attenzione per le persone che usano la tecnologia.

Leggi anche