【问题标题】:Is dynamic programming backtracking with cache动态规划回溯是否带缓存
【发布时间】:2014-05-20 00:57:22
【问题描述】:

我一直想知道这一点。没有任何书籍明确说明这一点。

回溯是探索所有可能性,直到我们发现一种可能性无法引导我们找到可能的解决方案,在这种情况下我们放弃它。

据我所知,动态编程的特点是子问题重叠。那么,动态编程可以说是使用缓存回溯(对于以前探索过的路径)吗?

谢谢

【问题讨论】:

  • 你所指的,使用缓存回溯通常称为“memoization”。记忆具有动态编程的大部分好处,而且痛苦少得多。 DP 和 Memoization 之间的主要区别在于 DP 使用 最佳 递归顺序,这通常需要证明它是最佳的。为了记录,动态规划是人们可以选择的最俗气的名字编程的“类型”。
  • @ldog: 记忆化是实现 DP 的一种方式,并且在每种情况下都会产生最多个子问题来解决(因为自下而上的 DP,你循环将它们全部填满的表格单元格可能会计算出您实际上不需要的子问题的解决方案)。如果您需要解决所有或大部分子问题,自底向上通常是一个较小的常数因子,速度更快,因为您可以避免将堆栈推入/弹出以进行递归以及记忆化解决方案中的if (notAlreadyComputed) 测试。
  • @ldog:“动态编程”中的“编程”是一个古老的术语,意思是“使用表格”,而不是与计算机有关。在“线性规划”这个短语中可以看到相同的词源。
  • 我认为到目前为止的答案中缺少一个要点:DP 从不做“回溯”,如递归到非最优子树。所以缓存和记忆 DP 之间有一个类比,但是在设计高效算法时要避免回溯
  • 显然,“动态规划”这个名字太俗气了,因为算法的发明者是hiding from his boss,他正在做数学研究。

标签: algorithm theory dynamic-programming backtracking


【解决方案1】:

这是动态编程的一个方面,但还有更多。

举个简单的例子,拿斐波那契数列:

F (n) =
        n = 0:  0
        n = 1:  1
        else:   F (n - 2) + F (n - 1)

我们可以将上面的代码称为“回溯”或“递归”。 让我们将其转换为“带缓存的回溯”或“带记忆的递归”:

F (n) =
       n in Fcache:  Fcache[n]
       n = 0:  0, and cache it as Fcache[0]
       n = 1:  1, and cache it as Fcache[1]
       else:  F (n - 2) + F (n - 1), and cache it as Fcache[n]

不过,还有更多。

如果一个问题可以通过动态规划来解决,那么就有一个状态和它们之间的依赖关系的有向无环图。 有一种状态让我们感兴趣。 还有一些我们马上就知道答案的基本状态。

  • 我们可以遍历该图,从我们感兴趣的顶点到它的所有依赖项,从它们依次到它们的所有依赖项,等等,在基本状态停止进一步分支。 这可以通过递归来完成。

  • 可以将有向无环图视为顶点上的偏序。我们可以拓扑排序该图并按排序顺序访问顶点。 此外,您还可以找到一些与您的偏序一致的简单全序

还要注意,我们经常可以观察到状态的一些结构。 例如,状态通常可以表示为整数或整数元组。 因此,与其使用通用缓存技术(例如,存储状态->值对的关联数组),我们可以预先分配一个更容易、更快速使用的常规数组。


回到我们的斐波那契示例,偏序关系就是状态 n >= 2 取决于状态 n - 1n - 2。 基本状态是n = 0n = 1。 与此顺序关系一致的简单总顺序是自然顺序:012...。 下面是我们的开始:

Preallocate array F with indices 0 to n, inclusive
F[0] = 0
F[1] = 1

好的,我们有访问各州的顺序。 现在,什么是“访问”? 还有两种可能:

(1) “Backward DP”:当我们访问一个状态u时,我们会查看它的所有依赖关系v并计算该状态u的答案:

for u = 2, 3, ..., n:
    F[u] = F[u - 1] + F[u - 2]

(2) “转发 DP”:当我们访问状态 u 时,我们会查看依赖它的所有状态 v,并在每个状态 v 中解释 u

for u = 1, 2, 3, ..., n - 1:
    add F[u] to F[u + 1]
    add F[u] to F[u + 2]

请注意,在前一种情况下,我们仍然直接使用斐波那契数的公式。 然而,在后一种情况下,命令式代码不能容易地用数学公式表示。 不过,在某些问题中,“前向 DP”方法更直观(暂时没有好的例子;有人愿意贡献它吗?)。


动态规划的另一种用途是难以用回溯来表示:Dijkstra 的算法也可以被认为是 DP。 在算法中,我们通过添加顶点来构造最优路径树。 当我们添加一个顶点时,我们使用这样一个事实,即到它的整个路径——除了路径中的最后一条边——已经被认为是最优的。 因此,我们实际上对子问题使用了最优解——这正是我们在 DP 中所做的事情。 不过,我们向树中添加顶点的顺序是事先不知道的。

【讨论】:

  • 我想指出所谓的“反向DP”,即递归形式可以直接以自下而上的DP方式使用——一旦你弄清楚了总顺序,就走那个顺序从基本状态到期望状态,并且递归函数保证在计算状态的解决方案时,它的所有依赖状态都已经解决了。
  • @KFL 是的,这是一个中间立场。将计算封装在一个函数中有助于推理解决方案。另一方面,在极端情况下,函数本身可能会成为瓶颈。
【解决方案2】:

没有。或者更确切地说。

在回溯中,您向下然后备份每条路径。但是,动态编程是自下而上的,所以你只能得到后备部分,而不是原来的下降部分。此外,动态规划中的顺序更广度优先,而回溯通常是深度优先。

另一方面,如您所描述的,记忆化(动态编程的近亲)经常作为回溯与缓存一起工作。

【讨论】:

  • 是的,memoization 是实现 DP 的一种方式(也是一种很好的方式,因为表格“自动”以正确的顺序填写)
  • 据我了解,记忆和“适当的”动态规划在复杂性和值计算的相对顺序方面是相同的;然而,记忆化虽然看起来很幼稚,但可能会跳过一些子问题的评估。
  • @Codor:这取决于您所说的“相对顺序”是什么意思。他们都在需要之前计算每个值,但这仍然留下了各种各样的有效排序。记忆化将遵循关于递归的即时排序,但 DP 通常不会。
  • @Chris Okasaki:是的,我的评论有点不准确;我的意思更像是您所说的,例如:“位于递归末尾的值首先被评估”。如果状态空间有多个维度,则某些“较早切片”中的值会在“较晚切片”之前计算。
【解决方案3】:

是和不是。

动态编程基本上是实现递归公式的一种有效方式,而top-down DP其实很多时候都是用递归+缓存来完成的:

def f(x):
  if x is in cache:
    return cache[x]
  else:
    res <- .. do something with f(x-k)
    cahce[x] <- res
    return res

请注意,自下而上的 DP 实现方式完全不同 - 但仍然几乎遵循递归方法的基本原则,并且在每一步都“计算”较小(已知)子问题的递归公式。

但是,为了能够使用 DP - 您需要有一些问题的特征,主要是 - 问题的最佳解决方案包括其子问题的最佳解决方案。它成立的一个例子是shortest-path problem(从st 的最佳路径通过u 必须包含从su 的最佳路径)。

Vertex-CoverBoolean satisfiability Problem等其他一些问题上不存在,因此不能用DP代替它的回溯解决方案。

【讨论】:

  • 它也适用于顶点覆盖等,但我们通常有成倍增长的不同子问题
  • @NiklasB。无论如何都不是直截了当,您可以创建一个满足所述特征的新问题 - 然后对其应用 DP,但不必从最佳子集构造最佳 VC。
【解决方案4】:

没有。你所说的使用缓存回溯基本上是memoization

在动态编程中,您是自下而上的。也就是说,您从不需要任何子问题的地方开始。特别是当你需要计算nth 步时,所有n-1 步都已经计算好了。

这不是记忆的情况。在这里,您从kth 步骤(您想要的步骤)开始,并在需要的地方继续解决前面的步骤。并且显然将这些值存储在某个地方,以便您以后可以访问它们。

综上所述,在记忆化和动态编程的情况下,运行时间没有区别。

【讨论】:

  • memoization其实是实现动态规划的一种手段
猜你喜欢
  • 2013-05-03
  • 2014-01-13
  • 2011-04-05
  • 1970-01-01
  • 2015-11-09
  • 2019-08-21
  • 1970-01-01
  • 1970-01-01
  • 2013-12-12
相关资源
最近更新 更多