Mise à jour
Bien que toute la discussion ci-dessous soit toujours utile, la réponse de propose un algorithme sous-jacent beaucoup plus agréable qui nous permet de sauter mon paramètremin
. (Je savais que je devrais avoir vérifié ça!) Cette compréhension permet une amélioration significative des performances par rapport à l'algorithme utilisé ci-dessous.
Pensez à la programmation dynamique (DP) comme une technique d'optimisation simple qui peut accélérer certaines procédures récursives. Si vos appels récursifs se répètent (comme pour les nombres de Fibonacci), alors stocker leurs résultats peut accélérer considérablement votre programme. Mais la logique sous-jacente est toujours un appel récursif. Alors résolvons d'abord ce programme de manière récursive et voyons où nous pourrions appliquer l'optimisation de DP.
(8, 4)
avec seulement cinq solutions est assez petit pour que, même si le temps est algorithmiquement exponentiel, il soit encore probablement gérable. Essayons une récursion simple. Et au début, construisons en fait la sortie au lieu de la compter afin de vérifier que nous faisons les choses correctement.
Cette version est basée sur l'idée que nous pouvons définir le premier nombre de la liste, en gardant une trace de cette valeur comme minimum pour les éléments restants, puis recommencer pour les positions restantes. Enfin, essayons à nouveau avec un nombre initial plus élevé. Ainsi que nos entrées n
et k
, nous devrons également garder un paramètre min
, que nous commençons à 1
.
Voici une version:
const f = (n, k, min = 1) =>
k < 1 || n < k * min
? []
: k == 1
? [[n]]
: [
... f (n - min, k - 1, min) .map (xs => [min, ...xs]),
... f (n, k, min + 1)
]
console .log (
f (8, 4) //~> [[1, 1, 1, 5], [1, 1, 2, 4], [1, 1, 3, 3], [1, 2, 2, 3], [2, 2, 2, 2]]
)
(Vous n'avez pas spécifié un tag de langage; si cette syntaxe Javascript ES6 n'est pas claire, nous pouvons réécrire dans un autre style.)
Étant donné que cela semble correct, nous pouvons écrire une version plus simple juste pour compter les résultats:
const f = (n, k, min = 1) =>
k < 1 || n < k * min
? 0
: k == 1
? 1
: f (n - min, k - 1, min) + f (n, k, min + 1)
console .log (
f (8, 4) //~> 5
)
Mais si nous allons essayer un ensemble plus grand, disons f(1000, 10) (qui, par inspection, devrait être 8867456966532531), cela pourrait prendre un peu de temps à calculer. Notre algorithme est probablement exponentiel. Il y a donc deux manières dont nous pourrions aborder la programmation dynamique pour cela. L'approche la plus évidente est l'approche bottom-up:
const f = (_n, _k, _min = 1) => {
const cache = {}
for (let n = 1; n <= _n; n ++) {
for (let k = 1; k <= Math.min(_k, n); k++) {
for (let min = n; min >= 0; min--) {
cache [n] = cache[n] || {}
cache [n] [k] = cache [n] [k] || {}
cache [n] [k] [min] =
k < 1 || n < k * min
? 0
: k == 1
? 1
: cache [n - min] [k - 1] [min] + cache [n] [k] [min + 1]
}
}
}
return cache [_n] [_k] [_min]
}
console.time('time taken')
console .log (
f (1000, 10) //~> 886745696653253
)
console.timeEnd('time taken')
Déterminer les limites correctes ici est délicat, ne serait-ce que parce que la récursion est basée sur une valeur croissante de min
. Il est probable que nous calculons des choses dont nous n'avons pas besoin en cours de route.
C'est aussi du code laid, perdant l'élégance et la lisibilité de l'original tout en ne gagnant que des performances.
Nous pouvons toujours conserver l'élégance en mémorisant notre fonction; ceci est l'approche top-down. En travaillant avec une fonction memoize
réutilisable, nous pouvons utiliser notre solution récursive presque intacte:
const memoize = (makeKey, fn) => {
const cache = {}
return (...args) => {
const key = makeKey(...args)
return cache[key] || (cache[key] = fn(...args))
}
}
const makeKey = (n, k, min) => `${n}-${k}-${min}`
const f = memoize(makeKey, (n, k, min = 1) =>
k < 1 || n < k * min
? 0
: k == 1
? 1
: f (n - min, k - 1, min) + f (n, k, min + 1)
)
console.time('time taken')
console .log (
f (1000, 10) //~> 886745696653253
)
console.timeEnd('time taken')
memoize
transforme une fonction qui calcule ses résultats à chaque appel en une fonction qui ne calcule ces résultats que la première fois qu'elle voit un ensemble spécifique d'entrées. Cette version vous oblige à fournir une fonction supplémentaire qui transforme les arguments en une clé unique. Il y a d'autres façons d'écrire cela, mais elles sont un peu plus laides. Ici, nous transformons simplement (8, 4, 1)
en "8-4-1"
, puis stockons le résultat sous cette clé. Il n'y a pas d'ambiguïté. La prochaine fois que nous appelons avec (8, 4, 1)
, le résultat déjà calculé sera renvoyé instantanément à partir du cache.
Remarquez qu'il y a une tentation d'essayer
const f = (...args) => {...}
const g = memoize(createKey, f)
Mais cela ne fonctionne pas, si les appels récursifs dans f
pointent vers f
. Et s'ils pointent vers g
, nous avons déjà mélangé la mise en œuvre et f
n'est plus autonome, donc il y a peu de raison pour cela. Ainsi nous l'écrivons comme memomize(createKey, (...args) => {...})
. Des techniques avancées qui offrent des alternatives dépassent le cadre de la discussion ici.
La décision entre DP bottom-up et DP top-down est une question compliquée. Vous verrez dans le cas ci-dessus que la version bottom-up s'exécute plus rapidement pour l'entrée donnée. Il y a un surcoût récursif pour les appels de fonction supplémentaires, et dans certains cas, vous pourriez être soumis à des limites de profondeur de récursion. Mais ce surcoût est parfois entièrement compensé par la technique top-down ne calculant que ce dont vous avez besoin. Le bottom-up calculera toutes les entrées plus petites (pour une certaine définition de "plus petites") afin de trouver votre valeur. Le top-down ne calculera que les valeurs nécessaires pour résoudre votre problème.
1 Une blague! J'ai trouvé la valeur seulement après avoir utilisé la programmation dynamique.