Cos'è la programmazione dinamica ?
In che cosa differisce dalla ricorsione, dalla memoizzazione, ecc.?
Ho letto l' articolo di Wikipedia su di esso, ma ancora non lo capisco davvero.
Cos'è la programmazione dinamica ?
In che cosa differisce dalla ricorsione, dalla memoizzazione, ecc.?
Ho letto l' articolo di Wikipedia su di esso, ma ancora non lo capisco davvero.
Risposte:
La programmazione dinamica avviene quando si utilizzano le conoscenze passate per semplificare la risoluzione di un problema futuro.
Un buon esempio è risolvere la sequenza di Fibonacci per n = 1.000,002.
Sarà un processo molto lungo, ma cosa succede se ti do i risultati per n = 1.000.000 e n = 1.000.001? All'improvviso il problema è diventato più gestibile.
La programmazione dinamica è molto utilizzata nei problemi di stringa, come il problema di modifica della stringa. Risolvi un sottoinsieme (i) del problema e quindi usi tali informazioni per risolvere il problema originale più difficile.
Con la programmazione dinamica, i risultati vengono archiviati in una sorta di tabella in generale. Quando hai bisogno della risposta a un problema, fai riferimento alla tabella e vedi se sai già di cosa si tratta. In caso contrario, usi i dati nella tabella per darti un trampolino di lancio verso la risposta.
Il libro Cormen Algorithms ha un grande capitolo sulla programmazione dinamica. E è gratuito su Google Libri! Dai un'occhiata qui.
La programmazione dinamica è una tecnica utilizzata per evitare di calcolare più volte lo stesso sottoproblema in un algoritmo ricorsivo.
Prendiamo l'esempio semplice dei numeri di Fibonacci: trovare il n ° numero di Fibonacci definito da
F n = F n-1 + F n-2 e F 0 = 0, F 1 = 1
Il modo ovvio per farlo è ricorsivo:
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
La ricorsione esegue molti calcoli non necessari perché un determinato numero di Fibonacci verrà calcolato più volte. Un modo semplice per migliorare questo è memorizzare nella cache i risultati:
cache = {}
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
if n in cache:
return cache[n]
cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
return cache[n]
Un modo migliore per farlo è quello di sbarazzarsi della ricorsione insieme valutando i risultati nel giusto ordine:
cache = {}
def fibonacci(n):
cache[0] = 0
cache[1] = 1
for i in range(2, n + 1):
cache[i] = cache[i - 1] + cache[i - 2]
return cache[n]
Possiamo anche usare uno spazio costante e memorizzare solo i risultati parziali necessari lungo la strada:
def fibonacci(n):
fi_minus_2 = 0
fi_minus_1 = 1
for i in range(2, n + 1):
fi = fi_minus_1 + fi_minus_2
fi_minus_1, fi_minus_2 = fi, fi_minus_1
return fi
Come applicare la programmazione dinamica?
La programmazione dinamica generalmente funziona per problemi che hanno un ordine intrinseco da sinistra a destra come stringhe, alberi o sequenze di numeri interi. Se l'algoritmo ricorsivo ingenuo non calcola più volte lo stesso sottoproblema, la programmazione dinamica non sarà di aiuto.
Ho fatto una raccolta di problemi per aiutare a capire la logica: https://github.com/tristanguigue/dynamic-programing
if n in cache
come nell'esempio dall'alto verso il basso o mi manca qualcosa.
La memorizzazione è il momento in cui si memorizzano i risultati precedenti di una chiamata di funzione (una funzione reale restituisce sempre la stessa cosa, dati gli stessi input). Non fa differenza per la complessità algoritmica prima che i risultati vengano archiviati.
La ricorsione è il metodo di una funzione che si chiama da sola, in genere con un set di dati più piccolo. Poiché la maggior parte delle funzioni ricorsive può essere convertita in funzioni iterative simili, anche questo non fa differenza per la complessità algoritmica.
La programmazione dinamica è il processo per risolvere i sotto-problemi più facili da risolvere e costruire la risposta da ciò. La maggior parte degli algoritmi DP sarà nei tempi di esecuzione tra un algoritmo Greedy (se ne esiste uno) e un algoritmo esponenziale (enumera tutte le possibilità e trova quello migliore).
È un'ottimizzazione del tuo algoritmo che riduce il tempo di esecuzione.
Mentre un algoritmo goloso è di solito chiamato ingenuo , poiché può essere eseguito più volte sullo stesso set di dati, la Programmazione dinamica evita questo insuccesso attraverso una più profonda comprensione dei risultati parziali che devono essere archiviati per aiutare a costruire la soluzione finale.
Un semplice esempio è attraversare un albero o un grafico solo attraverso i nodi che contribuirebbero con la soluzione, o mettere in una tabella le soluzioni che hai trovato finora in modo da poter evitare di attraversare gli stessi nodi più e più volte.
Ecco un esempio di un problema adatto alla programmazione dinamica, dal giudice online di UVA: Edit Steps Ladder.
Farò un rapido briefing sulla parte importante dell'analisi di questo problema, tratta dal libro Sfide di programmazione, ti suggerisco di dare un'occhiata.
Dai un'occhiata a quel problema, se definiamo una funzione di costo che ci dice quanto distano due stringhe, ne abbiamo due in considerazione i tre tipi naturali di modifiche:
Sostituzione: cambia un singolo carattere dal modello "s" a un carattere diverso nel testo "t", come cambiare "tiro" in "spot".
Inserimento - inserire un singolo carattere nel modello "s" per aiutarlo a far corrispondere il testo "t", come cambiare "ago" in "agog".
Eliminazione: elimina un singolo carattere dal motivo "s" per aiutarlo a far corrispondere il testo "t", ad esempio cambiando "ora" in "nostro".
Quando impostiamo ciascuna di queste operazioni al costo di un passo, definiamo la distanza di modifica tra due stringhe. Quindi, come lo calcoliamo?
Possiamo definire un algoritmo ricorsivo usando l'osservazione che l'ultimo carattere nella stringa deve essere abbinato, sostituito, inserito o eliminato. Tagliare i caratteri nell'ultima operazione di modifica lascia un'operazione di coppia che lascia una coppia di stringhe più piccole. Sia i e j l'ultimo carattere del prefisso pertinente di et, rispettivamente. ci sono tre coppie di stringhe più brevi dopo l'ultima operazione, corrispondenti alla stringa dopo una corrispondenza / sostituzione, inserimento o cancellazione. Se sapessimo il costo di modifica delle tre coppie di stringhe più piccole, potremmo decidere quale opzione porta alla soluzione migliore e scegliere quell'opzione di conseguenza. Possiamo imparare questo costo, attraverso la cosa fantastica che è la ricorsione:
#define MATCH 0 /* enumerated type symbol for match */ #define INSERT 1 /* enumerated type symbol for insert */ #define DELETE 2 /* enumerated type symbol for delete */ int string_compare(char *s, char *t, int i, int j) { int k; /* counter */ int opt[3]; /* cost of the three options */ int lowest_cost; /* lowest cost */ if (i == 0) return(j * indel(’ ’)); if (j == 0) return(i * indel(’ ’)); opt[MATCH] = string_compare(s,t,i-1,j-1) + match(s[i],t[j]); opt[INSERT] = string_compare(s,t,i,j-1) + indel(t[j]); opt[DELETE] = string_compare(s,t,i-1,j) + indel(s[i]); lowest_cost = opt[MATCH]; for (k=INSERT; k<=DELETE; k++) if (opt[k] < lowest_cost) lowest_cost = opt[k]; return( lowest_cost ); }
Questo algoritmo è corretto, ma è anche incredibilmente lento.
In esecuzione sul nostro computer, sono necessari diversi secondi per confrontare due stringhe di 11 caratteri e il calcolo scompare per non atterrare mai più su qualcosa di più.
Perché l'algoritmo è così lento? Ci vuole tempo esponenziale perché ricalcola i valori ancora e ancora e ancora. Ad ogni posizione nella stringa, la ricorsione si dirama in tre modi, il che significa che cresce a una velocità di almeno 3 ^ n - anzi, ancora più veloce poiché la maggior parte delle chiamate riduce solo uno dei due indici, non entrambi.
Quindi, come possiamo rendere pratico l'algoritmo? L'osservazione importante è che la maggior parte di queste chiamate ricorsive sta calcolando cose che sono già state calcolate in precedenza. Come lo sappiamo? Bene, ci possono essere solo | s | · | T | possibili chiamate ricorsive uniche, poiché esistono solo molte coppie distinte (i, j) che fungono da parametri delle chiamate ricorsive.
Memorizzando i valori per ognuna di queste coppie (i, j) in una tabella, possiamo evitare di ricalcolarli e cercarli come necessario.
La tabella è una matrice bidimensionale m in cui ciascuno dei | s | · | t | cell contiene il costo della soluzione ottimale di questo sottoproblema, oltre a un puntatore genitore che spiega come siamo arrivati a questa posizione:
typedef struct { int cost; /* cost of reaching this cell */ int parent; /* parent cell */ } cell; cell m[MAXLEN+1][MAXLEN+1]; /* dynamic programming table */
La versione di programmazione dinamica presenta tre differenze rispetto alla versione ricorsiva.
Innanzitutto, ottiene i suoi valori intermedi utilizzando la ricerca delle tabelle anziché le chiamate ricorsive.
** Secondo, ** aggiorna il campo genitore di ogni cella, che ci consentirà di ricostruire la sequenza di modifica in un secondo momento.
** Terzo, ** Terzo, è strumentato usando una
cell()
funzione obiettivo più generale invece di restituire m [| s |] [| t |] .cost. Questo ci consentirà di applicare questa routine a una più ampia classe di problemi.
Qui, un'analisi molto particolare di ciò che serve per raccogliere i risultati parziali più ottimali, è ciò che rende la soluzione "dinamica".
Ecco una soluzione alternativa e completa allo stesso problema. È anche "dinamico" anche se la sua esecuzione è diversa. Ti suggerisco di verificare l'efficacia della soluzione presentandola al giudice online di UVA. Trovo sorprendente come un problema così grave sia stato affrontato in modo così efficiente.
I bit chiave della programmazione dinamica sono "sotto-problemi sovrapposti" e "sottostruttura ottimale". Queste proprietà di un problema indicano che una soluzione ottimale è composta dalle soluzioni ottimali ai suoi sotto-problemi. Ad esempio, i problemi di percorso più breve presentano una sottostruttura ottimale. Il percorso più breve da A a C è il percorso più breve da A a qualche nodo B seguito dal percorso più breve da quel nodo B a C.
Più in dettaglio, per risolvere un problema del percorso più breve dovrai:
Poiché stiamo lavorando dal basso verso l'alto, abbiamo già soluzioni ai problemi secondari quando arriva il momento di utilizzarli, memorizzandoli.
Ricorda, i problemi di programmazione dinamica devono avere sia sotto-problemi sovrapposti che una sottostruttura ottimale. Generare la sequenza di Fibonacci non è un problema di programmazione dinamica; utilizza la memoizzazione perché presenta sotto-problemi sovrapposti, ma non ha una sottostruttura ottimale (perché non sono coinvolti problemi di ottimizzazione).
Programmazione dinamica
Definizione
La programmazione dinamica (DP) è una tecnica generale di progettazione di algoritmi per la risoluzione di problemi con sotto-problemi sovrapposti. Questa tecnica è stata inventata dal matematico americano "Richard Bellman" negli anni '50.
Idea chiave
L'idea chiave è quella di salvare le risposte della sovrapposizione di piccoli problemi secondari per evitare la ricomputazione.
Proprietà di programmazione dinamica
Sono anche molto nuovo alla programmazione dinamica (un potente algoritmo per un particolare tipo di problemi)
In parole semplici, pensa alla programmazione dinamica come ad un approccio ricorsivo con l'utilizzo delle conoscenze precedenti
La conoscenza precedente è ciò che conta di più qui, Tieni traccia della soluzione dei sotto-problemi che hai già.
Considera questo, l'esempio più basilare per dp da Wikipedia
Trovare la sequenza di fibonacci
function fib(n) // naive implementation
if n <=1 return n
return fib(n − 1) + fib(n − 2)
Consente di interrompere la chiamata di funzione con dire n = 5
fib(5)
fib(4) + fib(3)
(fib(3) + fib(2)) + (fib(2) + fib(1))
((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
(((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
In particolare, fib (2) è stato calcolato tre volte da zero. In esempi più grandi, vengono ricalcolati molti più valori di fib, o sotto-problemi, portando ad un algoritmo di tempo esponenziale.
Ora, proviamolo memorizzando il valore che abbiamo già scoperto in una struttura di dati diciamo una mappa
var m := map(0 → 0, 1 → 1)
function fib(n)
if key n is not in map m
m[n] := fib(n − 1) + fib(n − 2)
return m[n]
Qui stiamo salvando la soluzione dei sotto-problemi nella mappa, se non l'abbiamo già. Questa tecnica di salvataggio dei valori che avevamo già calcolato è definita Memoizzazione.
Alla fine, Per un problema, prova prima a trovare gli stati (possibili sotto-problemi e prova a pensare al miglior approccio di ricorsione in modo da poter utilizzare la soluzione del precedente sotto-problema in altri).
La programmazione dinamica è una tecnica per risolvere problemi con sotto-problemi sovrapposti. Un algoritmo di programmazione dinamica risolve ogni singolo problema una volta sola e quindi salva la sua risposta in una tabella (array). Evitare il lavoro di rielaborare la risposta ogni volta che si riscontra un problema secondario. L'idea alla base della programmazione dinamica è: evitare di calcolare due volte le stesse cose, di solito mantenendo una tabella di risultati noti di sotto-problemi.
I sette passaggi nello sviluppo di un algoritmo di programmazione dinamica sono i seguenti:
6. Convert the memoized recursive algorithm into iterative algorithm
un passaggio obbligatorio? Ciò significherebbe che la sua forma finale non è ricorsiva?
in breve, la differenza tra memoization di ricorsione e programmazione dinamica
La programmazione dinamica come suggerisce il nome sta usando il valore calcolato in precedenza per costruire dinamicamente la nuova soluzione successiva
Dove applicare la programmazione dinamica: se la tua soluzione si basa su una sottostruttura ottimale e un problema secondario sovrapposto, in quel caso sarà utile utilizzare il valore calcolato in precedenza, quindi non è necessario ricalcolarlo. È un approccio dal basso verso l'alto. Supponiamo che tu debba calcolare fib (n) in quel caso tutto ciò che devi fare è aggiungere il precedente valore calcolato di fib (n-1) e fib (n-2)
Ricorsione: sostanzialmente suddividere il problema in una parte più piccola per risolverlo facilmente, ma tenere presente che non eviterà il calcolo se abbiamo lo stesso valore calcolato in precedenza in un'altra chiamata di ricorsione.
Memoization: l'archiviazione di base del vecchio valore di ricorsione calcolato nella tabella è nota come memoization che eviterà il ricalcolo se è già stata calcolata da una chiamata precedente, quindi qualsiasi valore verrà calcolato una volta. Quindi prima di calcolare controlliamo se questo valore è già stato calcolato o meno se già calcolato quindi restituiamo lo stesso dalla tabella invece di ricalcolare. È anche un approccio top down
Ecco un semplice esempio di codice python di Recursive
, Top-down
, Bottom-up
approccio per la serie di Fibonacci:
def fib_recursive(n):
if n == 1 or n == 2:
return 1
else:
return fib_recursive(n-1) + fib_recursive(n-2)
print(fib_recursive(40))
def fib_memoize_or_top_down(n, mem):
if mem[n] is not 0:
return mem[n]
else:
mem[n] = fib_memoize_or_top_down(n-1, mem) + fib_memoize_or_top_down(n-2, mem)
return mem[n]
n = 40
mem = [0] * (n+1)
mem[1] = 1
mem[2] = 1
print(fib_memoize_or_top_down(n, mem))
def fib_bottom_up(n):
mem = [0] * (n+1)
mem[1] = 1
mem[2] = 1
if n == 1 or n == 2:
return 1
for i in range(3, n+1):
mem[i] = mem[i-1] + mem[i-2]
return mem[n]
print(fib_bottom_up(40))