L’approche de bas en haut (à la programmation dynamique) consiste d’abord à examiner les "plus petits" problèmes, puis à résoudre les problèmes plus vastes en utilisant la solution aux problèmes plus petits.
Le top-down consiste à résoudre le problème de manière "naturelle" et à vérifier si vous avez déjà calculé la solution au sous-problème.
Je suis un peu confus. Quelle est la différence entre ces deux?
rev4: Un commentaire très éloquent de l'utilisateur Sammaron a noté que, peut-être, cette réponse confondait-elle auparavant de haut en bas et de bas en haut. Tandis qu’à l’origine cette réponse (rev3) et d’autres réponses disaient que "l’approche ascendante est de la mémoisation" ("assume les sous-problèmes"), il peut s’agir de l’inverse (c’est-à-dire que "la hiérarchie peut être" assumer les sous-problèmes "et bottom-up "peut être" composer les sous-problèmes "). Auparavant, j'avais lu que la mémorisation était un type différent de programmation dynamique par opposition à un sous-type de programmation dynamique. Je citais ce point de vue en dépit de ne pas y adhérer. J'ai réécrit cette réponse pour rester agnostique de la terminologie jusqu'à ce que des références appropriées puissent être trouvées dans la littérature. J'ai également converti cette réponse en wiki de communauté. S'il vous plaît préférez les sources académiques. Liste de références: {Web: 1 , 2 } {Littérature: 5 }
La programmation dynamique consiste à ordonner vos calculs de manière à éviter de recalculer le travail en double. Vous avez un problème principal (la racine de votre arbre de sous-problèmes) et des sous-problèmes (sous-arbres). Les sous-problèmes se répètent et se chevauchent généralement .
Par exemple, considérons votre exemple préféré de Fibonnaci. Ceci est l'arborescence complète 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 peut être infini dans certaines branches, ce qui représente une non-terminaison, et donc le bas de l'arbre peut être infiniment grand. En outre, dans certains problèmes, vous pourriez ne pas savoir à quoi ressemble l'arbre complet avant Ainsi, vous aurez peut-être besoin d’une stratégie/d’un algorithme pour décider quels sous-problèmes révéler.)
Il existe au moins deux techniques principales de programmation dynamique qui ne s’excluent pas:
Mémoisation - Il s’agit d’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 l’ordre d’évaluation optimal. En règle générale, vous effectuez un appel récursif (ou un équivalent itératif) à partir de la racine et espérez que vous vous approcherez de l'ordre d'évaluation optimal ou que vous obtiendrez une preuve que vous l'aiderez à parvenir à l'ordre d'évaluation optimal. Vous devez vous assurer que l'appel récursif ne recalcule jamais un sous-problème car vous mettez en cache les résultats et les sous-arbres en double ne sont donc pas recalculés.
fib(100)
, appelez simplement ceci et appelez fib(100)=fib(99)+fib(98)
, qui appellerait fib(99)=fib(98)+fib(97)
, ... etc ..., qui appellerait fib(2)=fib(1)+fib(0)=1+0=1
. Ensuite, il résoudrait finalement fib(3)=fib(2)+fib(1)
, mais il ne sera pas nécessaire de recalculer fib(2)
, car nous l'avons mis en cache.Tabulation - Vous pouvez également considérer la programmation dynamique comme un algorithme "de remplissage de table" (bien que généralement multidimensionnel, ce "tableau" peut avoir une géométrie non euclidienne dans de très rares cas *). C'est comme une mémo, mais plus actif, et implique une étape supplémentaire: vous devez choisir, à l'avance, l'ordre exact dans lequel vous ferez vos calculs. Cela ne signifie pas que l'ordre doit être statique, mais que vous avez beaucoup plus de flexibilité que la mémorisation.
fib(2)
, fib(3)
, fib(4)
... mettant en cache chaque valeur afin de pouvoir calculer les suivantes plus facilement. Vous pouvez également penser que vous remplissez une table (une autre forme de mise en cache).(À son niveau le plus général, dans un paradigme de "programmation dynamique", je dirais que le programmeur considère l’arbre entier, , puis écrit un algorithme qui implémente une stratégie. pour évaluer les sous-problèmes susceptibles d’optimiser les propriétés souhaitées (généralement une combinaison de complexité temporelle et de complexité spatiale). Votre stratégie doit commencer quelque part, avec un sous-problème particulier, et peut-être même s’adapter en fonction des résultats de ces évaluations. sens général de "programmation dynamique", vous pouvez essayer de mettre en cache ces sous-problèmes, et plus généralement, évitez de revisiter les sous-problèmes avec une distinction subtile, par exemple les graphes dans diverses structures de données. Très souvent, ces structures de données sont à la base tableaux ou tableaux. Des solutions aux sous-problèmes peuvent être jetées si nous n’en avons plus besoin.)
[Auparavant, cette réponse faisait une déclaration sur la terminologie descendante ou ascendante; il existe clairement deux approches principales appelées mémoisation et tabulation qui peuvent être bijectées avec ces termes (bien que pas entièrement). Le terme général utilisé par la plupart des gens est toujours "Programmation dynamique" et certains disent "Mémorisation" pour désigner ce sous-type particulier de "Programmation dynamique". Cette réponse décline de dire ce qui est top-down et bottom-up jusqu'à ce que la communauté puisse trouver des références appropriées dans des articles académiques. En fin de compte, il est important de comprendre la distinction plutôt que la terminologie.]
La mémorisation est très facile à coder (vous pouvez généralement * écrire une annotation ou un wrapper "memoizer" qui le fait automatiquement pour vous), et devrait être votre première ligne d'approche. L'inconvénient de la tabulation est que vous devez proposer une commande.
* (ceci n’est en fait facile que si vous écrivez vous-même la fonction et/ou codez dans un langage de programmation impur/non fonctionnel ... par exemple si quelqu'un a déjà écrit une fonction fib
précompilée, appels récursifs à lui-même, et vous ne pouvez pas mémoriser la fonction comme par magie sans vous assurer que ces appels récursifs appellent votre nouvelle fonction mémoisée (et non la fonction originale non mémoisée))
Notez que les méthodes descendantes ou ascendantes peuvent être implémentées de haut en bas et de bas en haut, bien que cela puisse ne pas être naturel.
Avec la mémorisation, si l’arbre est très profond (par exemple, fib(10^6)
), vous manquerez d’espace de pile, car chaque calcul différé doit être mis sur la pile et vous en aurez 10 ^ 6.
L’une ou l’autre approche peut ne pas être optimale dans le temps si l’ordre dans lequel vous passez (ou essayez de) visiter les sous-problèmes n’est pas optimal, en particulier s’il existe plus d’une façon de calculer un sous-problème (normalement, la mise en cache résoudrait ce problème, mais il est théoriquement possible que la mise en cache pas dans certains cas exotiques). La mémorisation ajoute généralement votre complexité temporelle à votre complexité spatiale (par exemple, avec la tabulation, vous avez plus de liberté pour jeter des calculs. Par exemple, utiliser la tabulation avec Fib vous permet d'utiliser O(1) espace, mais la mémoisation avec Fib utilise O(N) espace de pile).
Si vous faites également un problème extrêmement compliqué, vous pourriez n'avoir d'autre choix que de faire de la tabulation (ou du moins de jouer un rôle plus actif dans le pilotage de la mémorisation où vous le souhaitez). De plus, si vous vous trouvez dans une situation dans laquelle l'optimisation est absolument essentielle et que vous devez l'optimiser, la tabulation vous permettra d'effectuer des optimisations que la mémorisation ne vous permettrait pas de faire de manière saine. À mon humble avis, en génie logiciel normal, aucun de ces deux cas ne se présente, je me contenterais donc d'utiliser la mémorisation ("une fonction qui cache ses réponses") à moins que quelque chose (tel que l'espace de pile) ne rende nécessaire la tabulation ... quoique techniquement, pour éviter une éruption de pile, vous pouvez 1) augmenter la limite de taille de la pile dans les langues qui le permettent, ou 2) consommer un facteur constant de travail supplémentaire pour virtualiser votre pile (ick), ou 3) un programme en mode continuation-pass, qui En fait, vous virtualisez également votre pile (vous n'êtes pas sûr de la complexité de cette opération, mais vous allez effectivement retirer la chaîne d'appels différée de la pile de taille N et la coller de facto dans N fonctions imbriquées successivement ... bien que dans certaines langues sans l’optimisation des appels en attente, vous devrez peut-être trampoler pour éviter une éruption de pile).
Nous énumérons ici des exemples présentant un intérêt particulier, qui ne sont pas simplement des problèmes généraux de PDD, mais qui distinguent de manière intéressante la mémoisation et la tabulation. Par exemple, une formulation peut être beaucoup plus facile que l’autre, ou il peut exister une optimisation qui nécessite essentiellement une tabulation:
Le DP de haut en bas et de bas en haut sont deux manières différentes de résoudre les mêmes problèmes. Considérez une solution de programmation mémorisée (top down) vs dynamique (bottom up) pour calculer les nombres de 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 beaucoup plus naturelle. Vous pouvez prendre une fonction récursive et la mémoriser par un processus mécanique (première recherche de réponse dans le cache et renvoi si possible, sinon calculez-la de manière récursive, puis, avant de retourner, sauvegardez le calcul dans le cache pour une utilisation ultérieure), tout en effectuant une analyse ascendante. la programmation dynamique exige que vous encodiez un ordre dans lequel les solutions sont calculées, de sorte qu'aucun "gros problème" ne soit calculé avant le plus petit problème dont il dépend.
Une caractéristique clé de la programmation dynamique est la présence de sous-problèmes qui se chevauchent . Autrement dit, le problème que vous essayez de résoudre peut être divisé en sous-problèmes, et nombre de ces sous-problèmes partagent des sous-problèmes. C'est comme "Diviser et conquérir", mais vous finissez par faire la même chose plusieurs fois. Un exemple que j’ai utilisé depuis 2003 pour enseigner ou expliquer ces choses: vous pouvez calculer nombres de Fibonacci de manière récursive.
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)
. Cela prendra très, très longtemps. À peu près autant de temps que fib(50)
lui-même! Cependant, beaucoup de travail inutile est fait. fib(50)
appellera fib(49)
et fib(48)
, mais les deux appels 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 par un appel direct depuis un autre fib(48)
, celle qui a été générée par le calcul de fib(49)
... Vous voyez donc que nous avons des sous-problèmes qui se chevauchent .
Bonne nouvelle: il n'est pas nécessaire de calculer plusieurs fois la même valeur. Une fois que vous l'avez calculé une fois, mettez le résultat en cache et utilisez la valeur mise en cache la prochaine fois! C'est l'essence 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. Commencez simplement par écrire une solution récursive, testez-la lors de petits tests, ajoutez une mémoisation (mise en cache des valeurs déjà calculées) et --- bingo! --- vous avez terminé.
En général, vous pouvez également écrire un programme itératif équivalent qui fonctionne de bas en haut, sans récursivité. Dans ce cas, ce serait l’approche la plus naturelle: faire une boucle de 1 à 50 en calculant tous les nombres de Fibonacci au fur et à mesure.
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 obtiendrez généralement un tableau plus détaillé du fonctionnement de l'algorithme. En pratique, lors de la résolution de problèmes non triviaux, je vous 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. Ecrivez une petite routine qui générerait beaucoup de tests, idéalement - tous petits tests jusqu'à une certaine taille --- et validez que les deux solutions donnent le même résultat. Après cela, utilisez la solution ascendante en production, mais conservez le code de haut en bas, commenté. Cela aidera les autres développeurs à comprendre ce que vous faites: le code ascendant peut être assez 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 surcharge des appels récursifs. Le débordement de pile peut également être un problème dans certains problèmes, et notez que cela peut beaucoup dépendre des données d'entrée. Dans certains cas, il est possible que vous ne puissiez pas écrire un test provoquant un débordement de pile si vous ne comprenez pas bien la programmation dynamique, mais cela peut arriver un jour.
À présent, il existe des problèmes pour lesquels l’approche descendante est la seule solution envisageable, car l’espace du problème est tellement vaste qu’il est impossible de résoudre tous les sous-problèmes. Cependant, la "mise en cache" fonctionne toujours dans un délai raisonnable, car votre entrée n'a besoin que d'une fraction du sous-problème à résoudre, up solution. D'autre part, il y a des situations où vous savez qu'il vous faudra résoudre tous sous-problèmes. Dans ce cas, continuez et utilisez une approche ascendante.
J'utiliserais personnellement top-bottom pour l'optimisation de paragraphe, c'est-à-dire problème d'optimisation de l'habillage (recherchez les algorithmes de coupure de ligne Knuth-Plass; au moins TeX l'utilise, et certains logiciels d'Adobe Systems utilisent un logiciel similaire. approche). Je voudrais utiliser de bas en haut pour le Transformée de Fourier rapide .
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
En cas de cinq premiers nombres de fibonacci
Bottom(first) number :1
Top (fifth) number: 5
Jetons maintenant un coup d'oeil à l'algorithme récursif de la série de 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, afin de générer un cinquième nombre, il nécessite des troisième et quatrième chiffres. Donc, ma récursivité commence réellement à partir de top (5), puis va jusqu'aux nombres inférieurs/inférieurs. Cette approche est en fait une approche descendante.
Pour éviter de faire plusieurs fois le même calcul, nous utilisons des techniques de programmation dynamique. Nous stockons la valeur précédemment calculée et la réutilisons. Cette technique s'appelle la mémorisation. La programmation dynamique ne se limite pas à la mémorisation, ce qui n’est pas nécessaire pour discuter du problème actuel.
de haut en bas
Permet de réécrire notre algorithme original et d’ajouter des techniques mémorisées.
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 reste top-down car l'algorithme part de la valeur la plus haute et descend à chaque étape pour obtenir notre valeur la plus élevée.
de bas en haut
Mais la question est, pouvons-nous commencer par le bas, comme à partir du premier numéro de fibonacci, puis remonter notre chemin. Permet de réécrire en utilisant ces techniques,
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 examinons cet algorithme, il commence en fait par des valeurs plus basses, puis par le haut. Si j'ai besoin du 5ème numéro de fibonacci, je calcule réellement le 1er, puis le second puis le troisième jusqu'au 5ème numéro. Ces techniques sont en fait appelées techniques de bas en haut.
Les deux derniers, algorithmes remplissent les exigences de programmation dynamique. Mais l'une est descendante et une autre, ascendante. Les deux algorithmes ont une complexité spatiale et temporelle similaire.
La programmation dynamique est souvent appelée mémorisation!
1.La démoisation est la technique descendante (commencer à résoudre le problème donné en le décomposant) et la programmation dynamique est une technique ascendante (commencer à résoudre du sous-problème trivial vers le problème donné)
2.DP trouve la solution en partant du ou des cas de base et progresse vers le haut. DP résout tous les sous-problèmes, parce qu'il le fait de bas en haut
Contrairement à la mémorisation, qui ne résout que les problèmes secondaires nécessaires
DP a le potentiel de transformer des solutions à force brute à temps exponentiel en algorithmes à temps polynomiaux.
Le DP peut être beaucoup plus efficace parce que son itératif
Au contraire, Memoization doit payer pour les frais généraux (souvent importants) dus à la récursivité.
Pour être plus simple, Memoization utilise l’approche descendante pour résoudre le problème, c’est-à-dire qu’il commence par le problème principal (principal), puis le divise en sous-problèmes et résout ces problèmes de manière similaire. Dans cette approche, le même sous-problème peut se produire plusieurs fois et consommer plus de cycle de la CPU, augmentant ainsi la complexité temporelle. Alors que dans la programmation dynamique, le même sous-problème ne sera pas résolu plusieurs fois mais le résultat précédent sera utilisé pour optimiser la solution.
Dire simplement que l'approche descendante utilise la récursion pour appeler encore et encore des problèmes de sous-traitants
Où, en tant qu’approche ascendante, utilisez le single sans appeler qui que ce soit et par conséquent, il est plus efficace.
Vous trouverez ci-dessous la solution DP pour le problème de modification de la distance, qui est de haut en bas. J'espère que cela vous aidera également à 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 assez bon et difficile si vous n'avez jamais résolu quelque chose comme ça auparavant.