【问题标题】:What is dynamic programming? [closed]什么是动态规划? [关闭]
【发布时间】:2010-11-07 02:48:36
【问题描述】:

什么是动态规划

它与递归、记忆等有何不同?

我看过上面的wikipedia article,但还是不太明白。

【问题讨论】:

  • 这里有一个来自 CMU 的 Michael A. Trick 的教程,我发现它特别有用:mat.gsia.cmu.edu/classes/dynamic/dynamic.html 当然,除了其他人推荐的所有资源(所有其他资源,特别是 CLR 和 Kleinberg,Tardos非常好!)。我喜欢这个教程的原因是它相当渐进地介绍了高级概念。它有点陈旧,但它是此处提供的资源列表的一个很好的补充。还可以查看 Steven Skiena 的页面和动态编程讲座:cs.sunysb.edu/~algorith/video-lectures http:
  • 我一直认为“动态编程”是一个令人困惑的术语——“动态”表示非静态,但什么是“静态编程”?而“... Programming”让人想起“Object Oriented Programming”和“Functional Programming”,暗示DP是一种编程范式。我真的没有更好的名字(也许是“动态算法”?),但我们被这个名字卡住太糟糕了。
  • @dimo414 这里的“编程”更接近于“线性规划”,属于一类数学优化方法。有关其他数学规划方法的列表,请参阅文章 Mathematical optimization
  • @dimo414 “编程”在此上下文中指的是表格方法,而不是编写计算机代码。 - 科尔曼
  • cs.stackexchange.com/questions/59797/…中描述的公交车票成本最小化问题最好用动态规划来解决。

标签: algorithm dynamic-programming


【解决方案1】:

动态编程是指您利用过去的知识更容易解决未来的问题。

一个很好的例子是求解 n=1,000,002 的斐波那契数列。

这将是一个很长的过程,但是如果我给你 n=1,000,000 和 n=1,000,001 的结果呢?突然间,问题变得更容易处理了。

动态规划在字符串问题中被大量使用,例如字符串编辑问题。您解决问题的一个子集,然后使用该信息解决更困难的原始问题。

使用动态编程,您通常将结果存储在某种表格中。当您需要问题的答案时,您可以参考该表并查看您是否已经知道它是什么。如果没有,您可以使用表格中的数据为自己提供一个通往答案的垫脚石。

Cormen 算法这本书有一个关于动态规划的精彩章节。而且它在 Google 图书上是免费的!看看here.

【讨论】:

  • 你不是刚刚描述了记忆化吗?
  • 当记忆化的函数/方法是递归的时候,我会说记忆化是动态编程的一种形式。
  • 好的答案,只会添加关于最佳子结构的提及(例如,沿着从 A 到 B 的最短路径的任何路径的每个子集本身就是假设距离度量的 2 个端点之间的最短路径观察三角不等式)。
  • 我不会说“更容易”,而是更快。一个常见的误解是 dp 解决了简单算法无法解决的问题,但事实并非如此。不是功能问题,而是性能问题。
  • 使用记忆,动态规划问题可以自顶向下的方式解决。即调用函数来计算最终值,然后该函数又递归地调用它来解决子问题。没有它,动态规划问题只能以自下而上的方式解决。
【解决方案2】:

动态编程是一种用于避免在递归算法中多次计算同一子问题的技术。

让我们以斐波那契数为例:找到由

定义的第n斐波那契数

Fn = Fn-1 + Fn-2 和 F0 = 0, F 1 = 1

递归

执行此操作的明显方法是递归:

def fibonacci(n):
    if n == 0:
        return 0
    if n == 1:
        return 1

    return fibonacci(n - 1) + fibonacci(n - 2)

动态规划

  • 自上而下 - 记忆

递归做了很多不必要的计算,因为给定的斐波那契数会被计算多次。一个简单的改进方法是缓存结果:

cache = {}

def fibonacci(n):
    if n == 0:
        return 0
    if n == 1:
        return 1
    if n in cache:
        return cache[n]

    cache[n] = fibonacci(n - 1) + fibonacci(n - 2)

    return cache[n]
  • 自下而上

一个更好的方法是通过以正确的顺序评估结果来完全摆脱递归:

cache = {}

def fibonacci(n):
    cache[0] = 0
    cache[1] = 1

    for i in range(2, n + 1):
        cache[i] = cache[i - 1] +  cache[i - 2]

    return cache[n]

我们甚至可以使用常量空间并在此过程中只存储必要的部分结果:

def fibonacci(n):
  fi_minus_2 = 0
  fi_minus_1 = 1

  for i in range(2, n + 1):
      fi = fi_minus_1 + fi_minus_2
      fi_minus_1, fi_minus_2 = fi, fi_minus_1

  return fi
  • 如何应用动态规划?

    1. 在问题中找到递归。
    2. 自上而下:将每个子问题的答案存储在一个表中,以避免重新计算它们。
    3. 自下而上:找到正确的顺序来评估结果,以便在需要时提供部分结果。

动态编程通常适用于具有固有从左到右顺序的问题,例如字符串、树或整数序列。如果朴素的递归算法不能多次计算同一个子问题,动态规划将无济于事。

我做了一个问题集合来帮助理解逻辑:https://github.com/tristanguigue/dynamic-programing

【讨论】:

  • 只是出于好奇澄清一些事情 - 在您看来,使用递归关系和记忆的递归实现是动态编程?
  • 感谢您的解释。是否存在自下而上的条件:if n in cache 与自上而下的示例一样,还是我遗漏了什么。
  • 那么我是否理解正确,任何将每次迭代计算的值用于后续迭代的循环都是动态规划的示例?
  • 您能否为您给出的解释提供任何参考,包括自上而下和自下而上的特殊情况?
【解决方案3】:

记忆是当你存储函数调用的先前结果时(一个真正的函数总是返回相同的东西,给定相同的输入)。在存储结果之前,算法复杂度没有任何区别。

递归是函数调用自身的方法,通常使用较小的数据集。由于大多数递归函数都可以转换为类似的迭代函数,因此这对算法复杂度也没有影响。

动态编程是解决更容易解决的子问题并从中构建答案的过程。大多数 DP 算法将处于贪婪算法(如果存在)和指数(枚举所有可能性并找到最佳的)算法之间的运行时间。

  • DP 算法可以通过递归实现,但并非必须如此。
  • DP 算法无法通过记忆来加速,因为每个子问题只被求解(或调用的“求解”函数)一次。

【讨论】:

  • “DP 算法无法通过记忆加速” 我会说这是不正确的。每个子问题(函数)都可以被调用数千次,所以如果你能用记忆化把它短路,那么整个算法的速度会加快
【解决方案4】:

这是对算法的优化,可缩短运行时间。

虽然贪心算法通常被称为朴素,因为它可能在同一组数据上运行多次,动态编程通过更深入地了解必须存储到的部分结果来避免这个陷阱帮助构建最终解决方案。

一个简单的示例是仅通过对解决方案有贡献的节点遍历树或图形,或者将您目前找到的解决方案放入表中,这样您就可以避免一遍又一遍地遍历相同的节点。

这是一个适合动态规划的问题示例,来自 UVA 的在线评委:Edit Steps Ladder.

我将简要介绍一下这个问题分析的重要部分,摘自《编程挑战》一书,建议你看看。

好好看看这个问题,如果我们定义一个成本函数来告诉我们两个字符串之间的距离,我们就有两个考虑三种自然类型的变化:

替换 - 将模式“s”中的单个字符更改为文本“t”中的不同字符,例如将“shot”更改为“spot”。

插入 - 将单个字符插入模式“s”以帮助它匹配文本“t”,例如将“ago”更改为“agog”。

删除 - 从模式“s”中删除单个字符以帮助其匹配文本“t”,例如将“hour”更改为“our”。

当我们将每个操作设置为花费一步时,我们定义了两个字符串之间的编辑距离。那么我们如何计算呢?

我们可以通过观察字符串中的最后一个字符必须匹配、替换、插入或删除来定义递归算法。在最后一次编辑操作中删除字符会留下一对操作留下一对更小的字符串。令 i 和 j 分别是 和 t 的相关前缀的最后一个字符。最后一次操作后有三对较短的字符串,对应匹配/替换、插入或删除后的字符串。如果我们知道编辑三对较小字符串的成本,我们可以决定哪个选项会导致最佳解决方案并相应地选择该选项。我们可以通过递归这个很棒的东西来了解这个成本:

#define MATCH 0 /* enumerated type symbol for match */
#define INSERT 1 /* enumerated type symbol for insert */
#define DELETE 2 /* enumerated type symbol for delete */


int string_compare(char *s, char *t, int i, int j)

{

    int k; /* counter */
    int opt[3]; /* cost of the three options */
    int lowest_cost; /* lowest cost */
    if (i == 0) return(j * indel(’ ’));
    if (j == 0) return(i * indel(’ ’));
    opt[MATCH] = string_compare(s,t,i-1,j-1) +
      match(s[i],t[j]);
    opt[INSERT] = string_compare(s,t,i,j-1) +
      indel(t[j]);
    opt[DELETE] = string_compare(s,t,i-1,j) +
      indel(s[i]);
    lowest_cost = opt[MATCH];
    for (k=INSERT; k<=DELETE; k++)
    if (opt[k] < lowest_cost) lowest_cost = opt[k];
    return( lowest_cost );

}

这个算法是正确的,但也慢得不可思议。

在我们的计算机上运行时,比较两个 11 个字符的字符串需要几秒钟,然后计算就消失了,再也不会出现在任何事情上。

为什么算法这么慢?它需要指数级的时间,因为它一次又一次地重新计算值。在字符串中的每个位置,递归都以三种方式分支,这意味着它以至少 3^n 的速度增长——实际上,甚至更快,因为大多数调用只减少两个索引中的一个,而不是两个索引。

那么我们怎样才能使算法实用呢? 重要的观察是,这些递归调用中的大多数都在计算之前已经计算过的东西。我们怎么知道?好吧,只能有|s| · |t|可能的唯一递归调用,因为只有那么多不同的 (i, j) 对作为递归调用的参数。

通过将每个 (i, j) 对的值存储在一个表中,我们可以 避免重新计算它们,只看 根据需要添加它们。

该表是一个二维矩阵 m,其中每个 |s|·|t|单元格包含该子问题的最优解的成本,以及解释我们如何到达该位置的父指针:

typedef struct {
int cost; /* cost of reaching this cell */
int parent; /* parent cell */
} cell;

cell m[MAXLEN+1][MAXLEN+1]; /* dynamic programming table */

动态编程版本与递归版本有三个不同。

首先,它使用表查找而不是递归调用来获取中间值。

**第二,**它更新每个单元格的父字段,这将使我们能够在以后重建编辑序列。

**Third,**Third,它使用更通用的目标cell() 函数进行检测,而不仅仅是返回 m[|s|][|t|].cost。这将使我们能够将此例程应用于更广泛的问题。

在这里,对收集最佳部分结果所需的非常特殊的分析是使解决方案成为“动态”解决方案的原因。

Here's 对同一问题的替代完整解决方案。即使它的执行方式不同,它也是一个“动态”的。我建议您通过将其提交给 UVA 的在线评委来查看该解决方案的效率。我发现如何如此有效地解决了如此繁重的问题。

【讨论】:

  • 存储真的需要动态规划吗?是否会跳过任何数量的工作来使算法成为动态的?
  • 必须收集最佳逐步结果以使算法“动态”。动态编程源于贝尔曼在 OR 中的工作,如果你说“跳过任何数量的单词都是动态编程”,那么你就是在贬低这个词,因为任何搜索启发式都是动态编程。 en.wikipedia.org/wiki/Dynamic_programming
【解决方案5】:

动态规划的关键是“重叠子问题”和“最优子结构”。问题的这些性质意味着最优解是由其子问题的最优解组成的。例如,最短路径问题表现出最优子结构。从 A 到 C 的最短路径是从 A 到某个节点 B 的最短路径,然后是从该节点 B 到 C 的最短路径。

更详细地说,要解决最短路径问题,您将:

  • 找出从起始节点到接触它的每个节点的距离(比如从 A 到 B 和 C)
  • 找出这些节点到接触它们的节点的距离(从 B 到 D 和 E,从 C 到 E 和 F)
  • 我们现在知道了从 A 到 E 的最短路径:它是我们访问过的某个节点 x(B 或 C)的 A-x 和 x-E 的最短和
  • 重复此过程,直到我们到达最终目标节点

因为我们是自下而上地工作,所以在使用子问​​题时,我们已经有了解决方案,方法是记忆它们。

请记住,动态规划问题必须同时具有重叠子问题和最优子结构。生成斐波那契数列不是动态规划问题;它利用记忆化,因为它有重叠的子问题,但它没有最优子结构(因为不涉及优化问题)。

【讨论】:

    【解决方案6】:

    动态编程

    定义

    动态规划 (DP) 是一种用于求解的通用算法设计技术 重叠子问题的问题。这种技术是美国人发明的 1950 年代的数学家“理查德·贝尔曼”。

    关键思想

    关键思想是保存重叠较小子问题的答案以避免重新计算。

    动态编程属性

    • 使用较小实例的解决方案解决实例。
    • 可能需要多次使用较小实例的解决方案, 所以将他们的结果存储在一个表格中。
    • 因此每个较小的实例只求解一次。
    • 额外空间用于节省时间。

    【讨论】:

      【解决方案7】:

      我对动态规划(一种针对特定类型问题的强大算法)也非常陌生

      用最简单的话来说,只需将动态编程视为一种使用先前知识的递归方法

      以前的知识是这里最重要的,跟踪您已经遇到的子问题的解决方案。

      考虑一下这个,来自维基百科的 dp 的最基本示例

      寻找斐波那契数列

      function fib(n)   // naive implementation
          if n <=1 return n
          return fib(n − 1) + fib(n − 2)
      

      让我们用 n = 5 来分解函数调用

      fib(5)
      fib(4) + fib(3)
      (fib(3) + fib(2)) + (fib(2) + fib(1))
      ((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
      (((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
      

      特别是,fib(2) 从头开始​​计算了 3 次。在更大的例子中,更多的 fib 值或子问题会被重新计算,从而导致指数时间算法。

      现在,让我们尝试将我们已经找到的值存储在一个数据结构中,比如Map

      var m := map(0 → 0, 1 → 1)
      function fib(n)
          if key n is not in map m 
              m[n] := fib(n − 1) + fib(n − 2)
          return m[n]
      

      如果我们还没有的话,我们将在地图中保存子问题的解决方案。这种保存我们已经计算过的值的技术称为记忆化。

      最后,对于一个问题,首先尝试找到状态(可能的子问题并尝试考虑更好的递归方法,以便您可以将先前子问题的解决方案用于进一步的解决方案)。

      【讨论】:

      • 直接抄袭维基百科。投反对票!!
      【解决方案8】:

      动态编程是一种解决重叠子问题的技术。 动态规划算法一次解决每个子问题 将其答案保存在表格(数组)中。 避免每次遇到子问题时重新计算答案的工作。 动态规划的基本思想是: 避免两次计算相同的东西,通常通过保留子问题的已知结果表。

      开发动态规划算法的七个步骤如下:

      1. 建立一个递归属性,为问题实例提供解决方案。
      2. 根据递归属性开发递归算法
      3. 查看问题的相同实例是否在递归调用中再次得到解决
      4. 开发记忆递归算法
      5. 查看内存中存储数据的模式
      6. 将记忆递归算法转换为迭代算法
      7. 根据需要使用存储优化迭代算法(存储优化)

      【讨论】:

      • 6. Convert the memoized recursive algorithm into iterative algorithm 是强制性步骤吗?这意味着它的最终形式是非递归的?
      • 不是强制的,是可选的
      【解决方案9】:

      简而言之,递归记忆和动态编程的区别

      顾名思义,动态编程就是使用之前的计算值来动态构造下一个新的解决方案

      在哪里应用动态规划:如果您的解决方案基于最优子结构和重叠子问题,那么在这种情况下,使用较早的计算值将很有用,因此您不必重新计算它。这是自下而上的方法。假设你需要计算 fib(n) 在这种情况下你需要做的就是将之前计算的 fib(n-1) 和 fib(n-2) 的值相加

      递归:基本上将您的问题细分为更小的部分以轻松解决它,但请记住,如果我们之前在其他递归调用中计算出相同的值,它不会避免重新计算。

      记忆化:基本上将旧计算的递归值存储在表中称为记忆化,如果它已经通过以前的调用计算过,它将避免重新计算,因此任何值都将计算一次。所以在计算之前我们检查这个值是否已经计算过,如果已经计算过,那么我们从表中返回相同的值而不是重新计算。这也是自上而下的方法

      【讨论】:

        【解决方案10】:

        这是一个简单的 python 代码示例,用于斐波那契数列的RecursiveTop-downBottom-up 方法:

        递归:O(2n)

        def fib_recursive(n):
            if n == 1 or n == 2:
                return 1
            else:
                return fib_recursive(n-1) + fib_recursive(n-2)
        
        
        print(fib_recursive(40))
        

        自上而下:O(n) 对更大的输入有效

        def fib_memoize_or_top_down(n, mem):
            if mem[n] is not 0:
                return mem[n]
            else:
                mem[n] = fib_memoize_or_top_down(n-1, mem) + fib_memoize_or_top_down(n-2, mem)
                return mem[n]
        
        
        n = 40
        mem = [0] * (n+1)
        mem[1] = 1
        mem[2] = 1
        print(fib_memoize_or_top_down(n, mem))
        

        自下而上:O(n) 为简单起见,输入尺寸较小

        def fib_bottom_up(n):
            mem = [0] * (n+1)
            mem[1] = 1
            mem[2] = 1
            if n == 1 or n == 2:
                return 1
        
            for i in range(3, n+1):
                mem[i] = mem[i-1] + mem[i-2]
        
            return mem[n]
        
        
        print(fib_bottom_up(40))
        

        【讨论】:

          猜你喜欢
          • 2011-10-07
          • 2015-08-15
          • 2012-01-01
          • 2017-02-24
          • 1970-01-01
          • 2011-11-29
          • 1970-01-01
          • 2021-02-06
          • 1970-01-01
          相关资源
          最近更新 更多