Cos'è la programmazione dinamica? [chiuso]


276

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.


1
Ecco un tutorial di Michael A. Trick della CMU che ho trovato particolarmente utile: mat.gsia.cmu.edu/classes/dynamic/dynamic.html È certamente in aggiunta a tutte le risorse che altri hanno raccomandato (tutte le altre risorse, specialmente CLR e Kleinberg, i Tardos sono molto bravi!). Il motivo per cui mi piace questo tutorial è perché introduce concetti avanzati abbastanza gradualmente. È un po 'vecchio materiale ma è una buona aggiunta all'elenco delle risorse presentate qui. Consulta anche la pagina e le lezioni di Steven Skiena sulla programmazione dinamica: cs.sunysb.edu/~algorith/video-lectures http:
Edmon

11
Ho sempre trovato "Programmazione dinamica" un termine confuso: "Dinamica" suggerisce non statico, ma che cos'è "Programmazione statica"? E "... Programmazione" ricorda "Programmazione orientata agli oggetti" e "Programmazione funzionale", suggerendo che DP è un paradigma di programmazione. Non ho davvero un nome migliore (forse "Algoritmi dinamici"?), Ma è un peccato che siamo bloccati con questo.
dimo414,

3
@ dimo414 La "programmazione" qui è più legata alla "programmazione lineare" che rientra in una classe di metodi di ottimizzazione matematica. Vedi l'articolo Ottimizzazione matematica per un elenco di altri metodi di programmazione matematica.
Syockit,

1
@ dimo414 "Programmare" in questo contesto si riferisce a un metodo tabulare, non alla scrittura di codice per computer. - Coreman
user2618142

Il problema di minimizzazione del costo del biglietto dell'autobus descritto in cs.stackexchange.com/questions/59797/… è meglio risolto nella programmazione dinamica.
truthadjustr,

Risposte:


210

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.


50
Non hai semplicemente descritto la memoizzazione però?
dreadwail,

31
Direi che la memoizzazione è una forma di programmazione dinamica, quando la funzione / metodo memoized è ricorsivo.
Daniel Huckstep,

6
Una buona risposta, aggiungerebbe solo una menzione sulla sottostruttura ottimale (ad esempio, ogni sottoinsieme di qualsiasi percorso lungo il percorso più breve da A a B è esso stesso il percorso più breve tra i 2 punti finali assumendo una metrica di distanza che osserva la disuguaglianza del triangolo).
Shea,

5
Non direi "più facile", ma più veloce. Un malinteso comune è che dp risolve problemi che gli algoritmi ingenui non possono e non è così. Non è una questione di funzionalità ma di prestazioni.
andandandand

6
Utilizzando la memoization, i problemi di programmazione dinamica possono essere risolti in modo dall'alto verso il basso. cioè chiamando la funzione per calcolare il valore finale, e quella funzione a sua volta lo chiama ricorsivamente per risolvere i sottoproblemi. Senza di essa, i problemi di programmazione dinamica possono essere risolti solo dal basso verso l'alto.
Pranav,

175

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

ricorsione

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)

Programmazione dinamica

  • Top Down - Memoization

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]
  • Dal basso verso l'alto

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?

    1. Trova la ricorsione nel problema.
    2. Top-down: memorizza la risposta per ciascun sottoproblema in una tabella per evitare di doverli ricalcolare.
    3. Bottom-up: trova l'ordine giusto per valutare i risultati in modo che siano disponibili risultati parziali quando necessario.

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


3
Questa è un'ottima risposta e anche la raccolta dei problemi su Github è molto utile. Grazie!
p4sh4,

Solo per curiosità nel chiarire le cose - secondo te, un'implementazione ricorsiva che usa una relazione di ricorrenza e la memoizzazione è una programmazione dinamica?
Codor,

Grazie per la spiegazione. C'è una condizione mancante dal basso verso l'alto: if n in cachecome nell'esempio dall'alto verso il basso o mi manca qualcosa.
DavidC

Capisco correttamente quindi che qualsiasi ciclo in cui i valori calcolati ad ogni iterazione vengono utilizzati nelle iterazioni successive è un esempio di programmazione dinamica?
Alexey,

Potresti fornire qualche riferimento all'interpretazione che hai fornito, inclusi i casi speciali top-down e bottom-up?
Alexey,

37

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

  • Gli algoritmi DP potrebbero essere implementati con ricorsione, ma non devono esserlo.
  • Gli algoritmi DP non possono essere velocizzati dalla memoizzazione, poiché ogni sotto-problema viene risolto solo una volta (o viene chiamata la funzione "risolve").

In parole povere. Vorrei che gli istruttori di algoritmi potessero spiegarlo bene.
Kelly S. francese,

21

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


Lo storage è davvero necessario per essere una programmazione dinamica? Una certa quantità di lavoro che salta non qualificherebbe un algoritmo come dinamico?
Nthalk,

È necessario raccogliere ottimale passo dopo passo i risultati per fare un algoritmo "dinamica". La programmazione dinamica deriva dal lavoro di Bellman in sala operatoria, se dici "che saltare una qualsiasi quantità di parole è una programmazione dinamica" stai svalutando il termine, poiché qualsiasi ricerca euristica sarebbe programmazione dinamica. it.wikipedia.org/wiki/Dynamic_programming
andandandand

12

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:

  • trova le distanze dal nodo iniziale ad ogni nodo toccandolo (diciamo da A a B e C)
  • trova le distanze da quei nodi ai nodi toccandoli (da B a D ed E e da C a E ed F)
  • ora conosciamo il percorso più breve da A a E: è la somma più breve di Ax e xE per alcuni nodi x che abbiamo visitato (o B o C)
  • ripetere questo processo fino a raggiungere il nodo di destinazione finale

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


1
IMHO, questa è l'unica risposta che ha senso in termini di programmazione dinamica. Sono curioso da quando le persone hanno iniziato a spiegare la DP usando i numeri di Fibonacci (poco rilevanti).
Terry Li,

@TerryLi, Potrebbe avere un "senso", ma non è facile da capire. Il problema del numero di Fibonacci è noto e di facile comprensione.
Ajay,

5

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

  • Un'istanza viene risolta utilizzando le soluzioni per istanze più piccole.
  • Le soluzioni per un'istanza più piccola potrebbero essere necessarie più volte, quindi archiviare i risultati in una tabella.
  • Pertanto ogni istanza più piccola viene risolta una sola volta.
  • Spazio aggiuntivo viene utilizzato per risparmiare tempo.

4

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


Furto diretto da Wikipedia. Downvoted !!
solidak,

3

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:

  1. Stabilire una proprietà ricorsiva che dia la soluzione a un'istanza del problema.
  2. Sviluppa un algoritmo ricorsivo secondo la proprietà ricorsiva
  3. Verifica se la stessa istanza del problema viene nuovamente risolta e di nuovo nelle chiamate ricorsive
  4. Sviluppa un algoritmo ricorsivo memorizzato
  5. Vedi lo schema nella memorizzazione dei dati in memoria
  6. Converti l'algoritmo ricorsivo memorizzato in algoritmo iterativo
  7. Ottimizza l'algoritmo iterativo utilizzando l'archiviazione come richiesto (ottimizzazione dell'archiviazione)

È 6. Convert the memoized recursive algorithm into iterative algorithmun passaggio obbligatorio? Ciò significherebbe che la sua forma finale non è ricorsiva?
truthadjustr,

non è obbligatorio, è facoltativo
Adnan Qureshi,

L'obiettivo è sostituire l'algoritmo ricorsivo utilizzato per archiviare i dati in memoria con un'iterazione sui valori memorizzati poiché una soluzione iterativa salva la creazione di uno stack di funzioni per ogni chiamata ricorsiva effettuata.
David C. Rankin,

1

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


-2

Ecco un semplice esempio di codice python di Recursive, Top-down, Bottom-upapproccio per la serie di Fibonacci:

Ricorsivo: O (2 n )

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))

Top-down: O (n) Efficiente per input più grandi

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))

Bottom-up: O (n) Per semplicità e dimensioni di input ridotte

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))

Il primo caso NON ha un tempo di esecuzione di n ^ 2, la sua complessità temporale è O (2 ^ n): stackoverflow.com/questions/360748/…
Sam

aggiornato grazie. @Sam
0xAliHn
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.