动态编程语言静态编程语言_动态编程–简介

动态编程语言静态编程语言

哇,自从我在这里写了东西以来已经有一段时间了。 在换工作,攻读博士学位和搬到新国家之间,我想你可能说我一直很忙。

但是与此同时,连同我生活中的所有这些变化,我几乎每天都在学习大量的新事物。 而且, 与其他许多人一样 ,我觉得以一种易于他人理解的方式撰写关于他们的文章,是巩固我自己的学习的最好方法之一。 因此,从现在开始,我将尝试在这里写更多的内容。

因此,在涵盖了这些内容之后,让我们转到重要的内容。 出于某种原因,最近我对从有点生锈的算法和数据结构中刷新旧概念非常感兴趣。 与这些旧概念一起出现的还有一些我从未听说过的新概念和新技术。 但是我将在单独的帖子中介绍它们。

在这篇文章中,我想谈谈动态编程。 如果您曾经上过算法大学课程,那么您可能已经听说过它。 如果您还没有这样做,那么这是一个学习极有用,容易和直观的东西的好机会(至少在您正确理解它的情况下)。

那么动态编程到底是什么? 我不会讲太多理论,因为如果您做一个简单的Google搜索,您已经可以找到所有的东西。 我将其定义为“智能递归”,并且像往常一样,我将通过一个示例对其进行说明。

解释动态编程的经典示例是斐波计算,因此我也将继续。 数字的斐波那契数的定义显然是递归的:

F(n)= F(n-1)+ F(n-2)F(1)= F(2)= 1

这意味着前10个斐波那契数字的顺序将变为:

1,1,2,3,5,8,13,21,34,55

您可能还会发现它定义为:

F(0)= F(1)= 1

因此顺序如下:

0,1,1,2,3,5,8,13,21,34,55

出于这篇文章的目的,这种区别是无关紧要的,但我会坚持第一个。

现在,此递归定义可以自然而巧妙地转换为以下递归方法:

public static long fibonacci(int n) {
    if (n < 3) return 1;
    return fibonacci(n-2) + fibonacci(n-1);
}

您甚至可以将它做成一个衬垫:

public static long fibonacci(int n) {
    return (n < 3) ? 1 : fibonacci(n-2) + fibonacci(n-1);
}

现在,该方法可以正常工作了,并且确实很优雅,但是随着您开始增加参数n ,执行时间会发生什么。 好吧,在我的笔记本电脑中,它几乎立即返回0到30之间的任何值。n为40时,它需要更长的时间:0.5秒。 但是对于等于50的n ,几乎需要一整分钟来计算正确的值。
您可能会认为一整分钟并不多,但是任何大于70的值对我来说都会杀死该应用程序,而50到70之间的任何值都需要花费太多时间才能完成。 多少? 我真的不知道,因为我没有耐心等待,但是肯定要超过30分钟。

那么这种方法有什么问题呢? 好吧,我还没有谈论算法的时间和空间复杂性 (我可能会在另一篇文章中提到),所以现在我只说算法的执行时间随着n的增加呈指数增长。 这就是为什么当您使用n = 40n = 50执行该方法时,会有如此大的时间差异的原因,因为2 ^ 40和2 ^ 50之间存在巨大差异。

通过仅对n的任何值跟踪其执行堆栈,也很容易看出该算法的行为原理。 让我们对n = 6进行简短处理。 下图显示了拨打电话的顺序。

斐波

查看代码,我们可以清楚地看到,要计算6的值,首先要计算5和4的值。但是,类似地,要计算5的值,我们需要4和3的值并计算值对于4,我们需要3和2的值。一旦到达2,就可以结束递归,因为我们知道结果(为1)。

这是此方法的问题,请注意我们调用了fibonacci(4)的次数以及我们调用fibonacci(3)的次数。 这是我们正在做的完全重复的工作。 如果永远不变,为什么要一遍又一遍地计算出相同的结果? 一旦我们第一次计算了fibonacci(3)或fibonacci(4),我们就可以保存该结果并在需要时重复使用。

这正是我智能递归的意思。 您可以拥有自然而简单的递归解决方案,但是您需要在重复工作的地方找出这些情况,并避免它们。 对于n = 6并不是什么大不了的事,但是随着n的增长,重复工作的数量也会成倍增长,直到它使应用程序变得无用为止。

那么我们如何去改进它呢? 我们只需要存储以前计算的值,就可以使用我们想要的任何结构。 在这种情况下,我将仅使用地图:

public static long fibonacci(int n) {
    if (n < 3) return 1;

    //Map to store the previous results
    Map<Integer,Long> computedValues = new HashMap<Integer, Long>();
    //The two edge cases
    computedValues.put(1, 1L);
    computedValues.put(2, 1L);

    return fibonacci(n,computedValues);
}

private static long fibonacci(int n, Map<Integer, Long> computedValues) {
    if (computedValues.containsKey(n)) return computedValues.get(n);

    computedValues.put(n-1, fibonacci(n-1,computedValues));
    computedValues.put(n-2, fibonacci(n-2,computedValues));

    long newValue = computedValues.get(n-1) + computedValues.get(n-2);
    computedValues.put(n, newValue);
    return newValue;
}

这个版本显然比第一个单行代码要长一些,但是仍然很容易理解。 现在,我们有2种方法,主要的公共方法(仅客户机使用n参数调用)和私有的方法(进行递归调用)。 第一种方法是初始化第二种方法所需的所有必要信息的有用位置。 在使用递归算法时,这是一种非常常见的模式。

在这种情况下,我们使用Map来保存已经计算的结果。 我们使用第一种方法中的2个基本案例初始化此映射,然后使用该映射调用第二种方法。 现在,我们不再总是计算该值,而是先检查它是否已经在地图上。 如果是,则只返回该值,否则我们计算并存储n-1n-2的斐波那契数。 在返回它们的和之前,我们确保存储n的最终值。

注意,我们仍然遵循与所见第一种方法相同的结构。 也就是说,我们从n开始,并根据需要计算较小的结果,以解决原始问题。 这就是为什么这种方法称为自上而下的原因。 稍后,我们将看到一种自下而上的方法,并将两者进行比较。 遵循自顶向下方法并保存先前计算的结果的这种技术也称为备忘录

这个版本好多少了? 好吧,虽然第一个版本花了将近一分钟的时间来计算n = 50的值,并且永不结束于更高的值,但是第二个记忆版本却给出了最多7000个n的即时答案。这是一个巨大的进步,但是通常,我们可以做得更好。

这个新的记忆版本的问题在于,即使我们保存结果以供以后重用,我们还是需要在第一次使用递归的情况下一直走到基本情况(当我们没有计算任何值时)但是我们还没有任何存储)。 因此,假设我们以n = 10000调用该方法。由于没有结果,我们以9999、9998、9997 ...…2递归调用该方法。 从递归开始返回后,所需的所有n-2值都已经存在,因此该部分相当快。

与任何递归算法一样,每个递归调用都占用堆栈上的一些空间。 并且,如果我们有足够的这些递归调用,堆栈最终将抛出StackOverflowException异常而炸毁。 当我们使用超过10000的值时,这正是我们第二种方法所发生的情况。

那么,有什么选择呢? 我之前提到过,记忆版本遵循自上而下的方法。 显而易见的事情是以自下而上的方式朝相反的方向前进。 从n的小值开始并建立结果,直至达到我们的目标。 我们仍将保存已经计算出的值,以在以后的阶段中使用它们,并避免重复工作。 该解决方案如下所示:

public static long fibonacciDP(int n) {
    long[] results = new long[n+1];
    results[1] = 1;
    results[2] = 1;
    for (int i = 3; i <= n; i++) {
        results[i] = results[i-1] + results[i-2];
    }
    return results[n];
}

这实际上比我们的记忆版本更简单。 我们只是创建一个数组来保存结果,使用2个基本案例对其进行初始化,然后从3开始迭代直到n 。 在每一步中,我们使用2个先前计算的值来计算当前值。 最后,我们返回n的正确值。

就复杂性和计算数量而言,此版本与第二个版本完全相同。 此处的区别在于最后一个版本是迭代的,因此不会占用堆栈上的空间作为递归版本。 现在,我们可以计算出不超过n = 500000的斐波那契数列,并且响应时间几乎是瞬时的。

但是我们还没有完全结束,还有很多事情可以改善。 即使我们从指数时间复杂度变为线性时间复杂度,我们也增加了所需的空间量。 在该算法的最后两个版本中,存储先前解所需的空间与n成正比。 如果我们创建一个长度为n的数组,这在我们的最后一个方法中可能很清楚。 n越大,我们需要的空间就越大。

但是实际上您可以看到,我们需要的唯一两个值是最后两个值( n-1n-2 )。 因此,我们实际上不需要跟踪所有先前的解决方案,只需跟踪最后两个即可。 我们可以修改最后一个方法来做到这一点:

public static long fibonacciDP(int n) {
    long n1 = 1;
    long n2 = 1;
    long current = 2;
    for (int i = 3; i <= n; i++) {
        current = n1 + n2;
        n2 = n1;
        n1 = current;
    }
    return current;
}

在这里,我们仅用3个变量替换了长度为n的数组:当前值和2个先前值。 因此,后一种方法具有线性的时间复杂度和恒定的空间复杂度,因为我们需要声明的变量数量与n的大小无关。

这与我们通过动态编程所能获得的一样好。 实际上有一个对数复杂度算法,但在这里我不会讨论。

因此,从这些示例中我们可以看到,动态编程没有什么神秘的或固有的困难。 它只需要您分析您的初始解决方案,确定重复工作的地点并通过存储已经计算的结果来避免重复工作。 通过这种非常简单的优化,您可以从大多数实际输入值无法使用的指数解变为多项式解。 具体来说,您想寻找动态编程可能解决的两个独特问题: 重叠子问题最佳子结构

重叠的子问题是指像斐波那契序列的问题,我们在这里看到,这里的主要问题(计算n个斐波那契),可以通过具有更小的子问题的解决方案, 这些小的子问题的解决方案来解决,需要的性质一次又一次。 在那些情况下,有必要存储结果并在以后重新使用它们。

但是,如果子问题是完全独立的,而我们只需要它们的结果一次,那么保存它们就没有任何意义。 可以划分为子问题但这些子问题不重叠的问题的一个示例是Binary Search 。 一旦我们丢弃了我们不关心的那一半,就再也不会访问它了。

最优子结构密切相关,这基本上意味着可以从子问题的最优解中高效构造出最优解。 在fibonacci示例中,此属性不是很明显,因为任何给定的n只有1个解。 但这在涉及某种优化的未来示例中将更加明显。

我将在这篇文章的结尾给出一些提示,这些提示将在尝试确定是否针对给定问题使用动态编程时使用:

  • 可以将问题分为同类的子问题吗?
  • 我可以通过重复定义定义上一个除法吗? 也就是说,将F(n)定义为F(n-1)的函数
  • 我需要多次还是仅一次将结果运用于子问题?
  • 使用自上而下或自下而上的方法是否更有意义?
  • 如果使用记忆式递归方法,是否需要担心堆栈?
  • 我是否需要保留所有先前的结果,还是可以优化空间并仅保留其中一些?

在接下来的文章中,我将介绍一些经典且非常有趣的问题,可以使用动态编程有效地解决这些问题。

干杯!

参考: 动态编程– JCG合作伙伴 Jose Luis在开发中的介绍,应该是博客的方式。

翻译自: https://www.javacodegeeks.com/2014/02/dynamic-programming-introduction.html

动态编程语言静态编程语言

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值