Programmation dynamique et memorisation: approches ascendantes et approches descendantes
Je ne suis pas sûr de comprendre l'approche du haut vers le bas avec la memoization et la méthode ascendante correctement.
de Bas en haut: C'est là que vous regardez d'abord les sous-problèmes "plus petits" et puis résoudre les sous-problèmes plus grands en utilisant la solution au problème plus petit.
de Haut en bas: Résolvez le problème de manière naturelle et vérifiez si vous avez déjà calculé la solution au sous-problème.
je suis un peu confus. Quelqu'un peut-il l'expliquer? Et quelle est la différence?
7 réponses
rev4: un commentaire très éloquent de L'utilisateur Sammaron a noté que, peut-être, cette réponse auparavant confuse top-down et bottom-up. Alors qu'à l'origine cette réponse (rev3) et d'autres réponses disaient que "bottom-up is memoization" ("assumez les sous-problèmes"), elle peut être l'inverse (c'est-à-dire "top-down" peut être "assumez les sous-problèmes" et "bottom-up" peut être "composez les sous-problèmes"). Précédemment, j'ai lu sur la memoization étant un type différent de programmation dynamique que contrairement à un sous-type de programmation dynamique. Je citais ce point de vue, même si je n'y souscrivais pas. J'ai réécrit cette réponse être agnostique de la terminologie jusqu'à ce que les références peuvent être trouvées dans la littérature. J'ai aussi converti cette réponse en un wiki communautaire. Veuillez préférer les sources universitaires. Liste des références: {Web: 1 , 2 } {la littérature: 5 }
Récapitulation
la programmation dynamique consiste à ordonner vos calculs d'une manière qui évite de recalculer le travail en double. Vous avez un problème principal (la racine de votre arborescence de sous-problèmes), et les sous-problèmes (sous-arbres). les sous-problèmes répètent et se chevauchent typiquement .
par exemple, considérez votre exemple préféré de Fibonnaci. C'est l'arbre complet des sous-problèmes, si nous faisions un appel récursif naïf:
TOP of the tree
fib(4)
fib(3)...................... + fib(2)
fib(2)......... + fib(1) fib(1)........... + fib(0)
fib(1) + fib(0) fib(1) fib(1) fib(0)
fib(1) fib(0)
BOTTOM of the tree
(dans certains autres problèmes rares, cet arbre pourrait être infini dans certaines branches, représentant la non-terminaison, et donc le fond de l'arbre peut être infiniment grand. En outre, dans certains problèmes que vous pourriez ne pas savoir ce que l'arbre ressemble à l'avance. Ainsi, vous pourriez avoir besoin d'une stratégie/algorithme pour décider quels sous-problèmes à révéler.)
Memoization, Tabulation
il y a au moins deux techniques de programmation dynamique qui ne s'excluent pas mutuellement:
-
Memoization - C'est une approche de laisser-faire: vous supposez que Vous avez déjà calculé tous les sous-problèmes et que vous n'avez aucune idée de ce que l'optimal ordre d'évaluation est. Typiquement, vous effectuerez un appel récursif (ou un équivalent itératif) à partir de la racine, et soit vous espérez vous rapprocher de l'ordre d'évaluation optimal, soit vous obtiendrez une preuve que vous allez vous aider arriver à l'ordre d'évaluation optimal. Vous vous assurez que l'appel récursif ne recompute jamais un sous-problème parce que vous cache les résultats, et donc les sous-arbres dupliqués ne sont pas recomputés.
- exemple: si vous calculez la séquence de Fibonacci
fib(100)
, vous appelleriez simplement ceci, et il appelleraitfib(100)=fib(99)+fib(98)
, qui appelleraitfib(99)=fib(98)+fib(97)
,...etc..., qui s'appelleraitfib(2)=fib(1)+fib(0)=1+0=1
. Puis il résoudrait finalementfib(3)=fib(2)+fib(1)
, mais il n'a pas besoin de recalculerfib(2)
, parce que nous l'avons mis en cache. - cela commence au sommet de l'arbre et évalue les sous-problèmes des feuilles/sous-arbres de retour vers la racine.
- exemple: si vous calculez la séquence de Fibonacci
-
Tabulation - Vous pouvez aussi penser à de la programmation dynamique comme un "tableau de combler un algorithme (bien que généralement multidimensionnelle, cette "table" peut avoir géométrie non-Euclidienne dans de très rares cas*). C'est comme memoization, mais plus active, et implique une étape supplémentaire: Vous devez choisir, à l'avance, l'ordre exact dans lequel vous allez faire vos calculs. Cela ne signifie pas que l'ordre doit être statique, mais que vous avez beaucoup plus de flexibilité que la memorisation.
- exemple: si vous exécutez fibonacci, vous pouvez choisir de calculer les nombres dans cet ordre:
fib(2)
,fib(3)
,fib(4)
... cachant chaque valeur de sorte que vous pouvez calculer les prochaines plus facilement. Vous pouvez également penser à remplir une table (une autre forme de cache). - personnellement, je n'entends pas beaucoup le mot "tabulation", mais c'est un terme très décent. Certains considèrent cette"programmation dynamique".
- avant d'exécuter l'algorithme, le programmeur considère l'arbre entier, puis écrit un algorithme pour évaluer les sous-problèmes dans un ordre particulier vers la racine, généralement de remplir un tableau.
- * note: parfois, la "table" n'est pas une table rectangulaire avec une connectivité de type réseau, en soi. Elle peut plutôt avoir une structure plus compliquée, comme un arbre, ou une structure spécifique au domaine problématique (par exemple des villes à distance de vol sur une carte), ou même un diagramme de treillis, qui, bien que de type grid, n'a pas de structure de connectivité haut-bas-gauche-droite, etc. Par exemple, user3290797 a relié une programmation dynamique exemple de recherche de l'ensemble indépendant maximum dans un arbre , qui correspond au remplissage des blancs dans un arbre.
- exemple: si vous exécutez fibonacci, vous pouvez choisir de calculer les nombres dans cet ordre:
(en général, dans un paradigme de "programmation dynamique", je dirais que le programmeur considère l'arbre entier, puis écrit un algorithme qui met en œuvre une stratégie pour évaluer les sous-problèmes qui peut optimiser toutes les propriétés que vous voulez (généralement un combinaison de la complexité temporelle et de la complexité spatiale). Votre stratégie doit commencer quelque part, avec un sous-problème particulier, et peut-être s'adapter sur la base des résultats de ces évaluations. Dans le sens général de "programmation dynamique", vous pourriez essayer de cacher ces sous-problèmes, et plus généralement, essayer d'éviter de revisiter les sous-problèmes avec une distinction subtile peut-être être le cas de graphiques dans diverses structures de données. Très souvent, ces structures de données sont à leur base comme des tableaux ou des tables. Les Solutions aux sous-problèmes peuvent être jetées si nous n'en avons plus besoin.)
[auparavant, cette réponse faisait une déclaration au sujet de la terminologie du haut vers le bas versus du bas vers le haut; il y a clairement deux approches principales appelées Memoization et Tabulation qui peuvent être en opposition avec ces termes (bien que pas entièrement). Le terme général que la plupart des gens utilisent est encore " programmation dynamique "et certains disent" Memoization "pour faire référence à ce sous-type particulier de "programmation dynamique"." Ce la réponse refuse de dire qui est du haut vers le haut et du bas vers le haut jusqu'à ce que la communauté puisse trouver des références appropriées dans les documents universitaires. En fin de compte, il est important de comprendre la distinction plutôt que la terminologie.]
pour et contre
facilité de codage
Memoization est très facile à coder (vous pouvez généralement* écrire une annotation "memoizer" ou fonction de wrapper qui le fait automatiquement pour vous), et ça devrait être ta première ligne d'approche. L'inconvénient de tabulation est que vous devez venir avec une commande.
*(c'est en fait seulement facile si vous écrivez la fonction vous-même, et/ou le codage dans un langage de programmation impure/non-fonctionnel... par exemple, si quelqu'un a déjà écrit une fonction précompilée fib
, il fait nécessairement des appels récursifs à lui-même, et vous ne pouvez pas mémoriser magiquement la fonction sans vous assurer que ces appels récursifs appellent votre nouveau memoized fonction (et non pas l'original unmemoized fonction))
récursivité
noter que les deux, descendant et ascendant, peuvent être mis en œuvre avec récursion ou remplissage itératif de la table, bien que cela ne soit pas naturel.
préoccupations pratiques
avec memoization, si l'arbre est très profond (par exemple fib(10^6)
), vous allez manquer d'espace de pile, parce que chaque calcul retardé doit être mis sur le pile, et vous aurez 10^6 d'entre eux.
optimalité
L'une ou l'autre approche peut ne pas être optimale dans le temps si l'ordre que vous passez (ou essayez de) visiter des sous-problèmes n'est pas optimal, spécifiquement s'il y a plus d'une façon de calculer un sous-problème (normalement la mise en cache résoudrait cela, mais il est théoriquement possible que la mise en cache ne pourrait pas dans certains cas exotiques). La Memoization ajoutera généralement votre complexité temporelle à votre complexité spatiale (par exemple avec tabulation vous avez plus de liberté pour jeter les calculs, comme utiliser la tabulation avec Fib vous permet d'utiliser L'espace O(1), mais la memoization avec Fib utilise l'espace de pile O (N).
optimisations avancées
si vous faites aussi des problèmes extrêmement compliqués, vous n'aurez peut-être pas d'autre choix que de faire de la tabulation (ou au moins de jouer un rôle plus actif en dirigeant la memoization là où vous voulez qu'elle aille). Aussi, si vous êtes dans une situation où l'optimisation est absolument essentiel et vous devez optimiser, tabulation vous permet de faire des optimisations qui memoization ne seraient autrement pas vous laisser faire sans danger. À mon humble avis, dans l'ingénierie logicielle normale, aucun de ces deux cas ne se présente jamais, donc je voudrais juste utiliser la memoization ("une fonction qui cache ses réponses") à moins que quelque chose (comme l'espace de pile) ne rende la tabulation nécessaire... bien que techniquement pour éviter une éruption de pile vous pouvez 1) Augmenter la limite de taille de pile dans les langues qui permettez-le, ou 2) mangez un facteur constant de travail supplémentaire pour virtualiser votre pile (ick), ou 3) programme dans le style continuation-passing, qui en fait virtualise également votre pile (pas sûr de la complexité de cela, mais fondamentalement, vous prendrez effectivement la chaîne d'appel différé de la pile de taille N et de fait coller dans n successivement emboîtées fonctions de thunk... bien que dans certaines langues sans optimisation de l'appel de queue, vous pouvez avoir à trampoline choses pour éviter une éruption de cheminée).
exemples plus compliqués
ici, nous énumérons des exemples d'intérêt particulier, qui ne sont pas seulement des problèmes de DP généraux, mais il est intéressant de distinguer la memorisation et la tabulation. Par exemple, une formulation peut être beaucoup plus facile que l'autre, ou il peut y avoir une optimisation qui nécessite essentiellement la tabulation:
- l'algorithme pour calculer la distance d'édition [ 4 ], intéressant comme un exemple non-trivial d'un algorithme de remplissage de table en deux dimensions
du haut vers le bas et le PDD du bas vers le haut sont deux façons différentes de résoudre les mêmes problèmes. Envisagez une solution de programmation memoized (du haut vers le bas) vs dynamique (du bas vers le haut) pour le calcul des nombres fibonacci.
fib_cache = {}
def memo_fib(n):
global fib_cache
if n == 0 or n == 1:
return 1
if n in fib_cache:
return fib_cache[n]
ret = memo_fib(n - 1) + memo_fib(n - 2)
fib_cache[n] = ret
return ret
def dp_fib(n):
partial_answers = [1, 1]
while len(partial_answers) <= n:
partial_answers.append(partial_answers[-1] + partial_answers[-2])
return partial_answers[n]
print memo_fib(5), dp_fib(5)
personnellement, je trouve la mémoïsation beaucoup plus naturelle. Vous pouvez prendre une fonction récursive et la mémoize par un processus mécanique (d'abord Réponse de recherche dans le cache et le retourner si possible, sinon le calculer de façon récursive et puis avant de retourner, vous sauvez le calcul dans le cache pour une utilisation future), alors que faire de la programmation dynamique ascendante exige que vous encodiez un ordre dans lequel les solutions sont calculées, de sorte qu'aucun "gros problème" n'est calculé avant le petit problème dont il dépend.
une caractéristique clé de la programmation dynamique est la présence de sous-problèmes se chevauchant . C'est le problème que vous essayez de résoudre peut être décomposé en sous-problèmes, et beaucoup de ces sous-problèmes partager subsubproblems. C'est comme" diviser pour mieux régner", mais on finit par faire la même chose de nombreuses fois. Un exemple que j'utilise depuis 2003 pour enseigner ou expliquer ces choses: vous pouvez calculer nombres de Fibonacci récursivement.
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
utilisez votre langue préférée et essayez de l'Exécuter pour fib(50)
. Il faudra un très, très longtemps. A peu près autant de temps que fib(50)
lui-même! Toutefois, beaucoup de travail inutile. fib(50)
appellera fib(49)
et fib(48)
, mais alors les deux finiront par appeler fib(47)
, même si la valeur est la même. En fait, fib(47)
sera calculé trois fois: par un appel direct de fib(49)
, par un appel direct de fib(48)
, et aussi par un appel direct d'un autre fib(48)
, celui qui a été engendré par le calcul de fib(49)
... Donc, vous voyez, nous avons sous-problèmes se chevauchant .
grande nouvelle: il n'est pas nécessaire de calculer la même valeur plusieurs fois. Une fois que vous l'avez calculé une fois, mettez en cache le résultat, et la prochaine fois utilisez la valeur mise en cache! C'est l'essence même de la programmation dynamique. Vous pouvez l'appeler "top-down", "memoization", ou tout ce que vous voulez. Cette approche est très intuitive et très facile à mettre en œuvre. Il suffit d'écrire une solution récursive d'abord, le tester sur de petits tests, ajouter memoization (mise en cache des valeurs déjà calculées), et --- bingo! --- vous avez terminé.
habituellement, vous pouvez aussi écrire un programme itératif équivalent qui fonctionne de bas en haut, sans récursion. Dans ce cas, ce serait l'approche la plus naturelle: boucle de 1 à 50 calculant tous les nombres de Fibonacci que vous allez.
fib[0] = 0
fib[1] = 1
for i in range(48):
fib[i+2] = fib[i] + fib[i+1]
dans tout scénario intéressant, la solution ascendante est généralement plus difficile à comprendre. Cependant, une fois que vous l'aurez compris, vous aurez habituellement une idée plus claire de la façon dont l'algorithme fonctionne. En pratique, pour résoudre des problèmes non triviaux, je recommande d'abord d'écrire l'approche descendante et de la tester sur de petits exemples. Ensuite, écrivez la solution ascendante et comparez les deux pour vous assurer que vous obtenez la même chose. Idéalement, comparez les deux solutions automatiquement. Écrire une petite routine qui générerait beaucoup de tests, idéalement -- tous petits tests jusqu'à une certaine taille --- et valider que les deux solutions donnent le même résultat. Après cela, utilisez la solution ascendante dans la production, mais garder le code Haut-Bas, commenté. Cela permettra aux autres développeurs de comprendre plus facilement ce que vous faites: le code ascendant peut être tout à fait incompréhensible, même si vous l'avez écrit et même si vous savez exactement ce que vous faites.
dans de nombreuses applications, l'approche ascendante est légèrement plus rapide en raison de la surabondance d'appels récursifs. Le débordement de la pile peut aussi être un problème dans certains problèmes, et notez que cela peut dépendre beaucoup des données d'entrée. Dans certains cas, il se peut que vous ne puissiez pas écrire un test provoquant un débordement de la pile si vous ne comprenez pas suffisamment la programmation dynamique, mais un jour cela peut encore arriver.
Maintenant, il y a des problèmes où l'approche descendante est la seule solution possible parce que l'espace de problème est si grand qu'il n'est pas possible de résoudre tous les sous-problèmes. Cependant, la" mise en cache " fonctionne toujours dans un délai raisonnable parce que votre entrée ne nécessite qu'une fraction des sous-problèmes à résoudre - - -mais il est trop délicat de définir explicitement, quels sous-problèmes vous devez résoudre, et donc d'écrire une solution ascendante. D'autre part, il ya des situations où vous savez que vous aurez besoin de résoudre tous sous-problèmes. Dans ce cas, allez sur et ascendantes.
j'utiliserais personnellement top-bottom pour l'optimisation de paragraphe a. K. a le Word wrap optimization problem (rechercher les algorithmes de cassage de ligne Knuth-Plass; au moins TeX l'utilise, et certains logiciels D'Adobe Systems utilise une approche similaire). J'utiliserais bottom-up pour la Fast Fourier Transform .
prenons la série fibonacci comme exemple
1,1,2,3,5,8,13,21....
first number: 1
Second number: 1
Third Number: 2
une autre façon de le dire,
Bottom(first) number: 1
Top (Eighth) number on the given sequence: 21
pour les cinq premiers numéros fibonacci
Bottom(first) number :1
Top (fifth) number: 5
voyons maintenant un exemple D'algorithme récursif de la série Fibonacci
public int rcursive(int n) {
if ((n == 1) || (n == 2)) {
return 1;
} else {
return rcursive(n - 1) + rcursive(n - 2);
}
}
Maintenant, si nous exécutons ce programme avec les commandes suivantes
rcursive(5);
si nous examinons de près l'algorithme, pour générer un cinquième nombre, il faut des numéros 3 et 4. Donc, ma récursion commence en fait par le haut (5) et va ensuite jusqu'aux nombres inférieurs/inférieurs. Cette approche est en fait une approche descendante.
pour éviter de faire le même calcul plusieurs fois, nous utilisons des techniques de programmation dynamique. Nous stockons la valeur calculée et la réutilisons. Cette technique s'appelle la mémoïsation. Il y a plus à la programmation dynamique autre que la mémorisation qui n'est pas nécessaire de discuter le problème actuel.
Top-Down
permet de réécrire notre algorithme d'origine et d'ajouter des techniques de mémoire.
public int memoized(int n, int[] memo) {
if (n <= 2) {
return 1;
} else if (memo[n] != -1) {
return memo[n];
} else {
memo[n] = memoized(n - 1, memo) + memoized(n - 2, memo);
}
return memo[n];
}
et nous exécutons cette méthode comme suit
int n = 5;
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
memoized(n, memo);
cette solution est toujours descendante que l'algorithme de départ à partir de la valeur supérieure et aller vers le bas à chaque étape pour obtenir notre valeur supérieure.
Bottom-Up
mais, la question Est, pouvons-nous commencer par le bas, comme du premier nombre de fibonacci puis marcher notre chemin vers le haut. Permet de le réécrire en utilisant cette technique,
public int dp(int n) {
int[] output = new int[n + 1];
output[1] = 1;
output[2] = 1;
for (int i = 3; i <= n; i++) {
output[i] = output[i - 1] + output[i - 2];
}
return output[n];
}
maintenant, si nous regardons dans cet algorithme, il commence en fait à partir de valeurs plus basses, puis aller en haut. Si j'ai besoin du 5e numéro de fibonacci, Je calcule le 1er, puis le 2e, puis le 3e, jusqu'au 5e numéro. Cette technique s'appelle en fait des techniques ascendantes.
deux dernières, algorithmes exigences de programmation dynamique complètes. Mais l'un est descendant et l'autre est ascendant. Les deux algorithmes ont la même complexité spatiale et temporelle.
la programmation dynamique est souvent appelée Memoization!
1.Memoization est la descendante de la technique(commencer à résoudre le problème en le décomposant) et de la programmation dynamique est une technique de(commencer à résoudre la banalité sous-problème, vers le problème donné)
2.DP trouve la solution en partant du(des) scénario (s) de base et en se déplaçant vers le haut. DP résout tous les sous-problèmes, car il le fait de bas en haut
contrairement à la Mémoization, qui ne résout que les sous-problèmes nécessaires
-
DP a le potentiel de transformer exponentielle en temps par la force brute des solutions en polynomiale en temps des algorithmes.
-
DP peut être beaucoup plus efficace, car son itératif
au contraire, la Memoization doit payer pour le important) frais généraux dus à la récursion.
pour être plus simple, Memoization utilise l'approche descendante pour résoudre le problème i.e. il commence avec le problème de base(principal) puis le divise en sous-problèmes et résoudre ces sous-problèmes de la même façon. Dans cette approche, le même sous-problème peut se produire plusieurs fois et consommer plus de cycle CPU, donc augmenter la complexité du temps. Alors que la programmation Dynamique d'un même sous-problème ne sera pas résolu plusieurs fois mais le résultat avant sera utilisé pour optimiser la solution.
simplement dire approche du haut vers le bas utilise la récursion pour appeler des problèmes de sous encore et encore
où comme approche du bas vers le haut utilisent le simple sans appeler personne et donc il est plus efficace.
ci-dessous est la solution basée DP pour le problème de distance D'édition qui est du haut vers le bas. J'espère qu'il sera également aider à comprendre le monde de la Programmation Dynamique:
public int minDistance(String word1, String word2) {//Standard dynamic programming puzzle.
int m = word2.length();
int n = word1.length();
if(m == 0) // Cannot miss the corner cases !
return n;
if(n == 0)
return m;
int[][] DP = new int[n + 1][m + 1];
for(int j =1 ; j <= m; j++) {
DP[0][j] = j;
}
for(int i =1 ; i <= n; i++) {
DP[i][0] = i;
}
for(int i =1 ; i <= n; i++) {
for(int j =1 ; j <= m; j++) {
if(word1.charAt(i - 1) == word2.charAt(j - 1))
DP[i][j] = DP[i-1][j-1];
else
DP[i][j] = Math.min(Math.min(DP[i-1][j], DP[i][j-1]), DP[i-1][j-1]) + 1; // Main idea is this.
}
}
return DP[n][m];
}
vous pouvez penser à son implémentation récursive chez vous. C'est tout à fait bon et stimulant Si vous n'avez pas résolu quelque chose comme ça avant.