什么是动态编程?[关闭]


276

什么是动态编程

它与递归,备忘录等有何不同?

我已经阅读了有关维基百科的文章,但我仍然不太了解。


1
这是我发现的CMU的Michael A. Trick的一个教程特别有用:mat.gsia.cmu.edu/classes/dynamic/dynamic.html当然,除了其他人推荐的所有资源(所有其他资源,特别是CLR)和Kleinberg,Tardos都很棒!)。我之所以喜欢本教程,是因为它相当渐进地介绍了高级概念。它是有点陈旧的资料,但是它是这里列出的资源的很好的补充。还可以查看Steven Skiena的页面和有关动态编程的讲座:cs.sunysb.edu/~algorith/video-lectures http:
Edmon

11
我一直发现“动态编程”是一个令人困惑的术语-“动态”表示不是静态的,但是“静态编程”是什么?“ ...编程”使我想到“面向对象的编程”和“功能编程”,这表明DP是一种编程范例。我真的没有更好的名字(也许是“动态算法”吗?),但是太糟糕了,我们只能坚持使用这个名字。
dimo414

3
@ dimo414这里的“编程”与“线性编程”更相关,后者属于一类数学优化方法。有关其他数学编程方法的列表,请参见数学优化文章。
syockit '16

1
@ dimo414在这种情况下,“编程”是指表格方法,而不是指编写计算机代码。-Coreman
user2618142 2016年

在动态编程中可以最好地解决cs.stackexchange.com/questions/59797/…中描述的巴士票费用最小化问题。
trueadjustr

Answers:


210

动态编程是当您使用过去的知识来简化将来的问题时。

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

这将是一个非常漫长的过程,但是如果我给您n = 1,000,000和n = 1,000,001的结果怎么办?突然,问题变得更加易于处理。

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

使用动态编程,通常会将结果存储在某种表中。当您需要问题的答案时,请参考表格并查看是否已经知道它是什么。如果不是,则使用表中的数据为自己找到答案的垫脚石。

《 Cormen算法》一书中有很长的一章是关于动态编程的。而且它在Google图书上是免费的!检查它在这里。


50
您不只是描述记忆吗?
dreadwail

31
我想说的是,当记忆的功能/方法是递归的时,记忆是动态编程的一种形式。
Daniel Huckstep 09年

6
好的答案只会增加关于最佳子结构的提及(例如,沿着从A到B的最短路径的任何路径的每个子集本身都是2个端点之间的最短路径,假设其距离度量遵循三角形不等式)。
乳木果

5
我不会说“更轻松”,而是更快。一个常见的误解是dp解决了天真的算法无法解决的问题,事实并非如此。与功能无关,而与性能有关。
andandandand

6
使用备忘录,可以以自上而下的方式解决动态编程问题。例如,调用该函数以计算最终值,然后该函数依次递归调用它以解决子问题。没有它,动态编程问题只能以自下而上的方式解决。
普拉纳夫

175

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

让我们以斐波那契数的简单示例为例:查找由定义的 n 斐波那契数

F n = F n-1 + F n-2和F 0 = 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


3
这是一个很好的答案,Github上的问题收集也非常有用。谢谢!
p4sh4

只是出于好奇而无法澄清问题-在您看来,使用递归关系和备忘录的递归实现是动态编程吗?
Codor

感谢您的解释。是否存在自下而上缺少的条件:if n in cache就像自上而下的示例一样,还是我缺少某些内容?
DavidC,

那么我是否正确理解,在每次迭代中使用在每次迭代中计算出的值的任何循环都是动态编程的示例?
Alexey,

您能为您的解释提供任何参考,包括自上而下和自下而上的特殊情况吗?
Alexey,

37

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

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

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

  • DP算法可以递归实现,但不一定必须如此。
  • 备注无法加快DP算法的速度,因为每个子问题只能解决一次(或调用“解决”功能)一次。

非常清楚地提出。我希望算法讲师能对此做很好的解释。
凯利·法国

21

这是对算法的优化,可减少运行时间。

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

一个简单的示例是仅遍历将对解决方案做出贡献的节点的树或图,或者将到目前为止已找到的解决方案放入表中,这样就可以避免遍历遍历相同的节点。

这是UVA在线法官提出的适合动态编程的问题示例:“ 编辑梯形图”。

我将简要介绍该问题分析的重要部分,该部分摘自《编程挑战》一书,建议您检查一下。

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

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

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

删除-从模式“ s”中删除单个字符以帮助其与文本“ t”匹配,例如将“小时”更改为“我们的”。

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

我们可以通过观察字符串中的最后一个字符必须匹配,替换,插入或删除来定义递归算法。在最后的编辑操作中切掉字符会留下一对操作,会留下一对较小的字符串。令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 */

动态编程版本与递归版本有三个差异。

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

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

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

这里,对收集最佳局部结果所需要采取的一种非常特殊的分析,是使解决方案成为“动态”解决方案。

这是对同一问题的替代完整解决方案。即使执行不同,它也是“动态”的。我建议您将解决方案提交给UVA的在线法官,以检查该解决方案的效率。我发现如何如此有效地解决这样一个沉重的问题感到惊讶。


存储真的需要进行动态编程吗?跳过任何工作都不会使算法具有动态性吗?
Nthalk 2011年

必须收集最佳的一步一步的结果,使算法“动态”。动态编程源于Bellman在OR中的工作,如果您说“跳过任何单词就是动态编程”,那么您正在贬低该术语,因为任何搜索启发式都是动态编程。en.wikipedia.org/wiki/Dynamic_programming
andandandand

12

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

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

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

由于我们是自下而上地工作,因此,当需要使用子问题时,我们已经通过记住它们来找到解决方案。

请记住,动态编程问题必须同时具有重叠的子问题和最佳子结构。产生斐波那契序列不是一个动态的编程问题;它利用备忘录是因为它具有重叠的子问题,但是它没有最优的子结构(因为不涉及优化问题)。


1
恕我直言,这是在动态编程方面唯一有意义的答案。我很好奇,因为当人们开始使用斐波那契数词(几乎不相关)来解释DP时。
Terry Li

@TerryLi,它可能正在发出“感觉”,但这并不容易理解。斐波那契数问题是已知的并且易于理解。
Ajay

5

动态编程

定义

动态编程(DP)是一种用于解决子问题重叠的通用算法设计技术。该技术是美国数学家“理查德·贝尔曼”(Richard Bellman)在1950年代发明的。

关键思想

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

动态编程属性

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

4

我对动态编程也非常陌生(针对特定类型问题的强大算法)

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

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

考虑一下这是Wikipedia中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)从头开始计算了三遍。在较大的示例中,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]

如果我们还没有的话,这里我们将其保存在地图中。我们已经计算出的这种保存值的技术称为“记忆化”。

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


从Wikipedia上直接摘取。不满意!
solidak

3

动态编程是一种用于解决子问题重叠的技术。动态编程算法可一次解决每个子问题,然后将其答案保存在表(数组)中。避免每次遇到子问题时都重新计算答案的工作。动态编程的基本思想是:避免两次计算相同的内容,通常是通过保留子问题的已知结果表来进行。

动态编程算法开发的七个步骤如下:

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

6. Convert the memoized recursive algorithm into iterative algorithm强制性步骤吗?这意味着它的最终形式是非递归的吗?
trueadjustr

不是强制性的,而是可选的
Adnan Qureshi

目的是通过对存储的值进行迭代来替换用于将数据存储在内存中的递归算法,因为迭代解决方案可为每次进行的递归调用节省函数堆栈的创建。
David C. Rankin

1

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

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

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

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

备注:基本上将旧的计算出的递归值存储在表中称为备注,如果先前的某个调用已对其进行了计算,它将避免重新计算,因此任何值都将被计算一次。因此,在计算之前,我们先检查该值是否已经计算出(如果已经计算出),那么我们将从表中返回该值,而不是重新计算。这也是自上而下的方法


-2

下面是一个简单的Python代码示例RecursiveTop-downBottom-up为斐波纳契数列的方法:

递归:O(2 n

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))

第一种情况的运行时间不为n ^ 2,其时间复杂度为O(2 ^ n):stackoverflow.com/questions/360748/…–
山姆

更新的感谢。@Sam
0xAliHn
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.