Le bottom-up consiste à regarder d' abord l' approche (à la programmation dynamique) aux « petits » sous - problèmes, puis résoudre les sous - problèmes plus importants en utilisant la solution aux petits problèmes.
Le top-down consiste à résoudre le problème de manière "naturelle" et à vérifier si vous avez déjà calculé la solution du sous-problème.
Je suis un peu confus. Quelle est la différence entre ces deux?
Réponses:
résumer
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 arborescence de sous-problèmes), et des sous-problèmes (sous-arborescences). Les sous-problèmes se répètent et se chevauchent généralement .
Par exemple, considérez votre exemple préféré de Fibonnaci. Voici l'arbre complet des sous-problèmes, si nous avons fait un appel récursif naïf:
(Dans certains autres problèmes rares, cet arbre peut être infini dans certaines branches, ce qui représente la non-terminaison, et ainsi le bas de l'arbre peut être infiniment grand. De plus, dans certains problèmes, vous pourriez ne pas savoir à quoi ressemble l'arbre complet avant Vous aurez peut-être besoin d’une stratégie / d’un algorithme pour décider des sous-problèmes à révéler.)
Mémorisation, tabulation
Il existe au moins deux techniques principales de programmation dynamique qui ne sont pas mutuellement exclusives:
Mémorisation - 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 rapprocherez de l'ordre d'évaluation optimal, ou obtenez une preuve que vous vous aiderez à arriver à l'ordre d'évaluation optimal. Vous vous assurez que l'appel récursif ne recalcule jamais un sous-problème car vous mettez en cache les résultats et ainsi les sous-arborescences dupliquées ne sont pas recalculées.
fib(100)
, vous appelleriez simplement ceci, et il appelleraitfib(100)=fib(99)+fib(98)
, qui appelleraitfib(99)=fib(98)+fib(97)
, ... etc ..., qui appelleraitfib(2)=fib(1)+fib(0)=1+0=1
. Ensuite, il serait finalement résolufib(3)=fib(2)+fib(1)
, mais il n'a pas besoin de recalculerfib(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, cette «table» peut avoir une géométrie non euclidienne dans de très rares cas *). C'est comme la mémorisation 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 doit pas impliquer que l'ordre doit être statique, mais que vous avez beaucoup plus de flexibilité que la mémorisation.
fib(2)
,fib(3)
,fib(4)
... la mise en cache toutes les valeurs pour que vous puissiez calculer les prochains plus facilement. Vous pouvez également penser que cela remplit une table (une autre forme de mise en cache).(Au plus général, dans un paradigme de "programmation dynamique", je dirais que le programmeur considère l'ensemble de l'arbre, alorsécrit un algorithme qui implémente une stratégie pour évaluer les sous-problèmes qui peuvent optimiser les propriétés que vous voulez (généralement une combinaison de complexité temporelle et spatiale). Votre stratégie doit commencer quelque part, avec un sous-problème particulier, et peut-être s’adapter en fonction des résultats de ces évaluations. Dans le sens général de "programmation dynamique", vous pouvez essayer de mettre en cache ces sous-problèmes, et plus généralement, éviter de revoir les sous-problèmes avec une distinction subtile pouvant être le cas des graphiques dans diverses structures de données. Très souvent, ces structures de données sont à leur base comme des tableaux ou des tableaux. 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 sur la terminologie descendante et ascendante; il existe clairement deux approches principales appelées mémorisation et tabulation qui peuvent être en bijection avec ces termes (mais pas entièrement). Le terme général que la plupart des gens utilisent est toujours «programmation dynamique» et certaines personnes disent «mémorisation» pour désigner ce sous-type particulier de «programmation dynamique». Cette réponse refuse de dire ce qui est descendant et ascendant jusqu'à ce que la communauté puisse trouver les références appropriées dans les articles universitaires. En fin de compte, il est important de comprendre la distinction plutôt que la terminologie.]
Avantages et inconvénients
Facilité de codage
La mémorisation est très facile à coder (vous pouvez généralement * écrire une annotation "mémo" ou une fonction wrapper 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 établir une commande.
* (ce n'est en fait facile que si vous écrivez la fonction vous-même, et / ou codez dans un langage de programmation impur / non fonctionnel ... par exemple si quelqu'un a déjà écrit une
fib
fonction précompilée , il effectue nécessairement des appels récursifs à lui-même, et vous ne pouvez pas mémoriser la fonction par magie sans vous assurer que ces appels récursifs appellent votre nouvelle fonction mémorisée (et non la fonction non mémorisée d'origine))Récursivité
Notez que le haut et le bas peuvent être implémentés avec une récursivité ou un remplissage de table itératif, bien que cela ne soit pas naturel.
Préoccupations pratiques
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 retardé doit être mis sur la pile, et vous en aurez 10 ^ 6.Optimalité
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 plusieurs façons 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 puisse pas dans certains cas exotiques). La mémorisation ajoutera généralement de votre complexité temporelle à votre complexité spatiale (par exemple, avec la tabulation, vous avez plus de liberté pour rejeter les calculs, comme l'utilisation de la tabulation avec Fib vous permet d'utiliser l'espace O (1), mais la mémorisation avec Fib utilise O (N) espace de pile).
Optimisations avancées
Si vous faites également un problème extrêmement compliqué, vous n'aurez peut-être pas d'autre choix que de faire une tabulation (ou du moins de jouer un rôle plus actif en dirigeant la mémorisation là où vous voulez qu'elle aille). De plus, si vous êtes dans une situation où l'optimisation est absolument critique et que vous devez optimiser, la tabulation vous permettra de faire des optimisations que la mémorisation ne vous permettrait pas autrement de faire de manière saine. À mon humble avis, dans l'ingénierie logicielle normale, aucun de ces deux cas ne se présente jamais, donc j'utiliserais simplement la mémorisation ("une fonction qui met en cache ses réponses") à moins que quelque chose (comme l'espace de pile) ne rende la tabulation nécessaire ... techniquement pour éviter une explosion de pile, vous pouvez 1) augmenter la limite de taille de pile dans les langues qui le permettent, ou 2) consommer un facteur constant de travail supplémentaire pour virtualiser votre pile (ick),
Exemples plus compliqués
Nous énumérons ici des exemples présentant un intérêt particulier, qui ne sont pas seulement des problèmes généraux de DP, mais distinguent de manière intéressante la mémorisation 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 une tabulation:
la source
python memoization decorator
; certains langages vous permettront d'écrire une macro ou un code qui encapsule le modèle de mémorisation. Le modèle de mémorisation n'est rien de plus que "plutôt que d'appeler la fonction, recherchez la valeur dans un cache (si la valeur n'est pas là, calculez-la et ajoutez-la d'abord au cache)".fib(513)
. La terminologie surchargée qui me semble gêne ici. 1) Vous pouvez toujours jeter les sous-problèmes dont vous n'avez plus besoin. 2) Vous pouvez toujours éviter de calculer les sous-problèmes dont vous n'avez pas besoin. 3) 1 et 2 peuvent être beaucoup plus difficiles à coder sans une structure de données explicite pour stocker les sous-problèmes, OU, plus difficile si le flux de contrôle doit tisser entre les appels de fonction (vous pourriez avoir besoin d'un état ou de continuations).DP de haut en bas et de bas en haut sont deux façons différentes de résoudre les mêmes problèmes. Considérons une solution de programmation mémorisée (de haut en bas) vs dynamique (de bas en haut) pour calculer les nombres de fibonacci.
Personnellement, je trouve la mémorisation 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 retournez-la si possible, sinon calculez-la récursivement puis avant de la retourner, vous enregistrez le calcul dans le cache pour une utilisation future), tout en faisant de bas en haut la programmation dynamique vous oblige à coder 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.
la source
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 beaucoup de ces sous-problèmes partagent des sous-sous-problèmes. C'est comme "Diviser pour conquérir", mais vous finissez par faire la même chose de nombreuses fois. Un exemple que j'utilise depuis 2003 pour enseigner ou expliquer ces questions: vous pouvez calculer les nombres de Fibonacci de manière récursive.
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 quefib(50)
lui-même! Cependant, beaucoup de travail inutile est en cours.fib(50)
appellerafib(49)
etfib(48)
, mais les deux finiront par appelerfib(47)
, même si la valeur est la même. En fait,fib(47)
sera calculé trois fois: par un appel direct defib(49)
, par un appel direct defib(48)
, et aussi par un appel direct d'un autrefib(48)
, celui qui a été engendré par le calcul defib(49)
... Donc vous voyez, nous avons des sous-problèmes qui se chevauchent .Bonne 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 le résultat en cache et la prochaine fois, utilisez la valeur mise en cache! C'est l'essence de la programmation dynamique. Vous pouvez l'appeler "de haut en bas", "mémorisation" ou tout ce que vous voulez. Cette approche est très intuitive et très simple à mettre en œuvre. Écrivez d'abord une solution récursive, testez-la sur de petits tests, ajoutez une mémorisation (mise en cache de valeurs déjà calculées), et --- bingo! --- vous avez terminé.
Habituellement, 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: boucle de 1 à 50 en calculant tous les nombres de Fibonacci au fur et à mesure.
Dans tout scénario intéressant, la solution ascendante est généralement plus difficile à comprendre. Cependant, une fois que vous l'avez compris, vous aurez généralement une vue d'ensemble beaucoup plus claire du fonctionnement de l'algorithme. En pratique, lors de la résolution de 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. Écrivez une petite routine qui générerait de nombreux tests, idéalement - touspetits tests jusqu'à une certaine taille --- et valider que les deux solutions donnent le même résultat. Après cela, utilisez la solution ascendante en production, mais gardez le code de haut en bas, commenté. Cela permettra aux autres développeurs de comprendre plus facilement 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, vous ne pourrez peut-être pas écrire un test provoquant un débordement de pile si vous ne comprenez pas assez bien 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 des problèmes 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 temps raisonnable car votre entrée n'a besoin que d'une fraction des sous-problèmes pour être résolue --- mais il est trop difficile de définir explicitement, quels sous-problèmes vous devez résoudre, et donc d'écrire un fond- solution. D'un autre côté, il existe des situations où vous savez que vous devrez résoudre tous les sous-problèmes. Dans ce cas, continuez et utilisez de bas en haut.
Personnellement, j'utiliserais de haut en bas pour l'optimisation de paragraphe, c'est-à-dire le problème d'optimisation du wrapping Word (recherchez les algorithmes de rupture de ligne Knuth-Plass; au moins TeX l'utilise, et certains logiciels d'Adobe Systems utilisent une approche similaire). J'utiliserais de bas en haut pour la transformation de Fourier rapide .
la source
Prenons la série fibonacci comme exemple
Une autre façon de le dire,
En cas de cinq premiers numéros de fibonacci
Jetons maintenant un coup d'œil à l'algorithme de la série récursive de Fibonacci à titre d'exemple
Maintenant, si nous exécutons ce programme avec les commandes suivantes
si nous examinons de près l'algorithme, pour générer un cinquième nombre, il faut des 3e et 4e nombres. Donc, ma récursivité commence en fait à partir du 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 précédemment calculée et la réutilisons. Cette technique s'appelle la mémorisation. Il y a plus à la programmation dynamique que la mémorisation qui n'est pas nécessaire pour discuter du problème actuel.
De haut en bas
Réécrivons notre algorithme original et ajoutons des techniques mémorisées.
Et nous exécutons cette méthode comme suit
Cette solution est toujours descendante car l'algorithme part de la valeur supérieure et va vers le bas à chaque étape pour obtenir notre valeur maximale.
De bas en haut
Mais, la question est, pouvons-nous commencer par le bas, comme à partir du premier numéro de fibonacci, puis marcher vers le haut. Réécrivons-le en utilisant ces techniques,
Maintenant, si nous examinons cet algorithme, il commence en fait à partir de valeurs inférieures, puis va en haut. Si j'ai besoin du cinquième numéro de fibonacci, je calcule en fait le premier, puis le deuxième puis le troisième jusqu'au cinquième numéro. Ces techniques sont en fait appelées techniques ascendantes.
Deux derniers, les algorithmes remplissent les exigences de programmation dynamique. Mais l'un est descendant et un autre est ascendant. Les deux algorithmes ont une complexité spatiale et temporelle similaire.
la source
La programmation dynamique est souvent appelée mémorisation!
La mémorisation est la technique descendante (commencez à résoudre le problème donné en le décomposant) et la programmation dynamique est une technique ascendante (commencez à résoudre à partir du sous-problème trivial, jusqu'au 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, car il le fait de bas en haut
DP a le potentiel de transformer des solutions de force brute à temps exponentiel en algorithmes à temps polynomial.
DP peut être beaucoup plus efficace car il est itératif
Pour être plus simple, la mémorisation utilise l'approche descendante pour résoudre le problème, c'est-à-dire qu'elle commence par le problème principal (principal) puis le décompose en sous-problèmes et résout ces sous-problèmes de la même manière. Dans cette approche, le même sous-problème peut survenir plusieurs fois et consommer plus de cycle CPU, augmentant ainsi la complexité du temps. Alors qu'en programmation dynamique, le même sous-problème ne sera pas résolu plusieurs fois mais le résultat antérieur sera utilisé pour optimiser la solution.
la source
Le simple fait de dire que l'approche descendante utilise la récursivité pour appeler les problèmes Sub encore et encore,
alors que l'approche ascendante utilise le single sans appeler personne et donc c'est plus efficace.
la source
Voici la solution basée sur DP pour le problème d'édition de distance qui est de haut en bas. J'espère que cela aidera également à comprendre le monde de la programmation dynamique:
Vous pouvez penser à sa mise en œuvre récursive chez vous. C'est assez bon et stimulant si vous n'avez pas encore résolu quelque chose comme ça.
la source
De haut en bas : garder une trace de la valeur calculée jusqu'à présent et renvoyer le résultat lorsque la condition de base est remplie.
Bottom-Up : Le résultat actuel dépend du résultat de son sous-problème.
la source