更新
尽管下面的所有讨论仍然有用,the answer from גלעד ברקן 提供了一个更好的底层算法,允许我们跳过我的min 参数。 (我知道我应该查一下这个!)这种理解可以显着提高下面使用的算法的性能。
将动态规划 (DP) 视为一种可以加速某些递归过程的简单优化技术。如果你的递归调用是重复的(就像斐波那契数一样),那么存储它们的结果可以大大加快你的程序。但底层逻辑仍然是递归调用。所以让我们先递归求解这个程序,看看我们可以在哪里应用 DP 优化。
(8, 4) 只有五个解决方案足够小,即使时间是算法上的指数,它仍然可能是可控的。让我们尝试一个简单的递归。首先,让我们实际构建输出而不是计算输出,以便仔细检查我们做的事情是否正确。
这个版本基于这样的想法,我们可以设置列表的第一个数字,跟踪该值作为剩余元素的最小值,然后重复剩余位置。最后,我们用更高的初始数字再试一次。除了n 和k 输入之外,我们还需要保留一个min 参数,我们从1 开始。
这是一个版本:
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]]
)
(您没有指定语言标签;如果 Javascript ES6 语法不清楚,我们可以改写成另一种风格。)
既然这看起来是对的,我们可以写一个更简单的版本来计算结果:
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
)
但如果我们要尝试更大的集合,比如 f(1000, 10)(根据检查,应该是 8867456966532531),计算可能需要一些时间。我们的算法可能是指数级的。因此,我们可以通过两种方式进行动态规划。最明显的是自下而上的方法:
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')
如果没有其他原因,在这里找出正确的边界是很棘手的,因为递归是基于min 的递增值。我们很可能在计算过程中不需要的东西。
这也是丑陋的代码,失去了原始的优雅和可读性,而只获得了性能。
我们仍然可以通过记忆我们的函数来保持优雅;这是自上而下的方法。通过使用可重用的memoize 函数,我们可以几乎完整地使用我们的递归解决方案:
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 将在每次调用时计算其结果的函数转换为仅在第一次看到特定输入集时才计算结果的函数。此版本要求您提供一个附加函数,将参数转换为唯一键。还有其他的写法,但它们有点难看。这里我们只是将(8, 4, 1) 转换为"8-4-1",然后将结果存储在该键下。没有歧义。下次我们用(8, 4, 1)调用时,已经计算好的结果会立即从缓存中返回。
注意有尝试的诱惑
const f = (...args) => {...}
const g = memoize(createKey, f)
但这不起作用,如果f 中的递归调用指向f。如果他们指向g,我们已经混淆了实现,f 不再是独立的,所以没有什么理由。因此我们将其写为memomize(createKey, (...args) => {...})。 offer alternatives 的高级技术不在此处讨论范围。
在自下而上的 DP 和自上而下的 DP 之间做出决定是一个复杂的问题。您将在上面的案例中看到,对于给定的输入,自下而上的版本运行得更快。附加函数调用有一些递归开销,在某些情况下,您可能会受到递归深度限制。但这有时完全被自上而下的技术所抵消,只计算你需要的东西。自下而上将计算所有较小的输入(对于“较小”的某些定义)以找到您的值。自上而下只会计算解决问题所必需的值。
1 开个玩笑!我是在使用动态规划后才发现的。