自下而上和自上而下有什么区别?


176

自下而上方法(用于动态编程)在于首先查看“较小”的子问题,然后使用较小问题的解决方案解决较大的子问题。

自上而下的在于,如果你已经计算出了解决方案之前,子问题解决一个“自然的方式”,并检查问题。

我有点困惑。两者有什么区别?


Answers:


247

rev4:Sammaron用户非常有说服力的评论指出,也许这个答案以前使自上而下和自下而上混淆。虽然最初这个答案(rev3)和其他答案说“自下而上是记忆”(“假设子问题”),但可能是相反的(即“自上而下”可能是“假设子问题”和“自下而上”可能是“组成子问题”)。以前,我读过备忘录是与动态编程子类型相对的另一种动态编程。尽管没有订阅,但我还是引用了该观点。我将这个答案重写为与术语无关,直到在文献中找到适当的参考文献为止。我还将此答案转换为社区Wiki。请选择学术来源。参考文献列表: 5 }

回顾

动态编程就是以一种避免重新计算重复工作的方式对计算进行排序。您有一个主要问题(子问题树的根)和子问题(子树)。子问题通常重复和重叠

例如,考虑您最喜欢的Fibonnaci示例。如果我们进行了幼稚的递归调用,这就是子问题的完整树:

TOP of the tree
fib(4)
 fib(3)...................... + fib(2)
  fib(2)......... + fib(1)       fib(1)........... + fib(0)
   fib(1) + fib(0)   fib(1)       fib(1)              fib(0)
    fib(1)   fib(0)
BOTTOM of the tree

(在其他一些罕见的问题中,该树在某些分支中可能是无限的,表示不终止,因此树的底部可能会无限大。此外,在某些问题中,您可能不知道完整树的前面是什么样子时间。因此,您可能需要一个策略/算法来确定要揭示哪些子问题。)


记忆化,制表

动态编程中至少有两种主要技术不互相排斥:

  • 备注-这是一种自由放任的方法:您假设您已经计算了所有子问题,并且不知道最佳评估顺序是什么。通常,您将从根目录执行递归调用(或某些迭代等效项),或者希望您接近最佳评估顺序,或者希望获得证明可以帮助您达到最佳评估顺序。您将确保递归调用永远不会重新计算子问题,因为您缓存了结果,因此不会重新计算重复的子树。

    • 示例:如果您正在计算斐波那契数列fib(100),则只需调用它,它就会调用fib(100)=fib(99)+fib(98),它会调用fib(99)=fib(98)+fib(97),... etc ...,它会调用fib(2)=fib(1)+fib(0)=1+0=1。然后它将最终解决fib(3)=fib(2)+fib(1),但是不需要重新计算fib(2),因为我们将其缓存了。
    • 它从树的顶部开始,并评估从叶子/子树到根的子问题。
  • 制表-您还可以将动态编程视为一种“表格填充”算法(尽管通常是多维的,但这种“表格”在极少数情况下可能具有非欧几里得几何形状*)。这就像记忆,但更加活跃,并且涉及一个额外的步骤:必须提前选择执行计算的确切顺序。这并不意味着该订单必须是静态的,而是比备忘录要灵活得多。

    • 例如:如果要执行斐波那契,你可能会选择在这个顺序来计算数字:fib(2)fib(3)fib(4) ...每一个缓存值,以便可以更容易地计算下一个人。您也可以将其视为填满表格(另一种缓存形式)。
    • 我个人很少听到“制表”一词,但这是一个非常体面的术语。有人认为这是“动态编程”。
    • 在运行算法之前,程序员会考虑整个树,然后编写一种算法,以朝根的特定顺序评估子问题,通常在表格中进行填写。
    • *脚注:有时,“表格”本身并不是具有网格状连接性的矩形表格。相反,它可能具有更复杂的结构,例如树或特定于问题域的结构(例如,在地图上飞行距离之内的城市),甚至是网格图,而网格图却没有网格例如,user3290797链接了一个动态编程示例,该示例在树中找到最大独立集,这对应于在中填充空白。

(最一般地说,在“动态编程”范式中,我想说的是程序员考虑了整个树,然后编写了一种算法,该算法实现了评估子问题的策略,该子问题可以优化所需的任何属性(通常是时间复杂性和空间复杂性)。您的策略必须从某个特定的子问题开始,并且可能会根据这些评估的结果进行调整。在一般意义上的“动态编程”中,您可以尝试缓存这些子问题,更普遍的是, ,请尝试避免以细微的区别重新审视子问题,例如在各种数据结构中的图形的情况下。这些数据结构通常是它们的核心,如数组或表。如果不这样做,则可以丢弃子问题的解决方案不再需要它们。)

[以前,此答案说明了自上而下与自下而上的术语;显然有两种主要的方法称为“记忆化”和“制表”,它们与这些术语可能是双射的(尽管不是全部)。大多数人使用的通用术语仍然是“动态编程”,有些人说“记忆化”是指“动态编程”的特定子类型。在社区可以在学术论文中找到适当的参考文献之前,这个答案拒绝说是自上而下和自下而上的。最终,重要的是要了解区别而不是术语。]


利弊

易于编码

记忆非常容易编写代码(通常,您可以*编写一个自动为您完成的“记忆”注释或包装函数),这应该是您的第一步。列表的缺点是您必须提出命令。

*(实际上只有在您自己编写函数和/或使用不纯/非函数式编程语言进行编码时,这才容易...例如,如果某人已经编写了预编译fib函数,则必须对其进行递归调用,并且您必须先确保那些递归调用调用新的已记忆功能(而不是原始未记忆功能),才能神奇地记忆该功能

递归性

请注意,自顶向下和自底向上都可以通过递归或迭代表填充来实现,尽管这可能不是很自然。

实际问题

使用备忘录,如果树很深(例如fib(10^6)),您将用完堆栈空间,因为每个延迟的计算都必须放在堆栈上,并且其中将有10 ^ 6。

最优性

如果您发生(或尝试)访问子问题的顺序不是最佳的,则两种方法都不是最佳时间,特别是如果存在一种以上的子问题计算方法(正常情况下,缓存可以解决此问题,但从理论上讲,缓存可能在某些特殊情况下不适用)。记忆化通常会增加您的时间复杂度,从而增加空间复杂度(例如,使用制表法,您可以自由地放弃计算,例如使用Fib的制表法可以使用O(1)空间,而使用Fib的制表法则可以使用O(N)堆栈空间)。

高级优化

如果您还在做一个非常复杂的问题,则可能别无选择,只能进行制表(或至少在将备忘录移至所需位置时扮演更积极的角色)。同样,如果您处于优化绝对重要且必须进行优化的情况下,则制表将使您能够进行优化,而备忘录不会让您以理智的方式进行优化。以我的拙见,在正常的软件工程中,这两种情况都不会出现,因此,除非有必要(例如堆栈空间)使制表成为必需,否则我将仅使用备忘录(“缓存其答案的函数”)。从技术上讲,为避免堆栈爆裂,您可以1)以允许的语言增加堆栈大小限制,或2)消耗恒定的额外工作量来虚拟化堆栈(ick),


更复杂的例子

在这里,我们列出了特别感兴趣的示例,这些示例不仅是一般的DP问题,而且还有趣地区分了记忆和制表。例如,一种配方可能比另一种配方容易得多,或者可能存在一种基本需要列表的优化:

  • 计算编辑距离[ 4 ] 的算法,作为二维表格填充算法的一个重要例子而有趣

3
@ coder000001:对于python示例,您可以谷歌搜索python memoization decorator;某些语言可以让您编写封装了记忆模式的宏或代码。记忆模式无非是“而不是调用函数,而是从高速缓存中查找值(如果该值不存在,请先计算并将其添加到高速缓存中)”。
ninjagecko 2012年

15
我没有看到任何人提及此内容,但我认为Top down的另一个优点是您只会稀疏地构建查找表/缓存。(即,您在实际需要的地方填写值)。因此,除了易于编码之外,这可能也是优点。换句话说,自上而下可以节省您的实际运行时间,因为您不需要计算所有内容(虽然运行时间可能好得多,但渐近运行时间却相同)。然而,它需要额外的存储器以保持附加堆栈帧(同样,内存消耗“可”(只可能)双但渐近它是相同的。
InformedA

2
我的印象是,自顶向下的方法将解决方案缓存到重叠的子问题,这是一种称为备忘录的技术。填充表格并避免重新计算重叠子问题的自底向上技术称为制表。在使用动态编程时可以使用这些技术,动态编程是指解决子问题以解决更大的问题。这似乎与该答案矛盾,因为该答案在许多地方使用动态编程而不是制表。谁是正确的?
Sammaron

1
@Sammaron:嗯,你说的很对。我也许应该已经在Wikipedia上检查了我的资料,但找不到。在稍微检查一下cstheory.stackexchange之后,我现在同意“自下而上”将意味着事先知道底部(制表),而“自上而下”则是假定您解决了子问题/子树。当时我发现该术语不明确,并且我在双重视图中解释了这些短语(“自上而下”假设您解决了子问题并记住了,“自上而下”则知道了您将要解决的子问题并可以将其制表)。我将尝试在编辑中解决此问题。
ninjagecko

1
@mgiuffrida:堆栈空间有时根据编程语言而有所不同。例如,在python中,尝试执行记忆式递归fib将失败fib(513)。我觉得这里的术语过于繁琐。1)您可以随时丢弃不再需要的子问题。2)您始终可以避免计算不需要的子问题。3)如果没有显式的数据结构来存储子问题,则1和2可能很难编写,或者,如果控制流必须在函数调用之间进行编织(您可能需要状态或延续),则难于编码。
ninjagecko 2015年

76

自上而下和自下而上的DP是解决相同问题的两种不同方法。考虑用于计算斐波那契数的记忆(自上而下)与动态(自下而上)的编程解决方案。

fib_cache = {}

def memo_fib(n):
  global fib_cache
  if n == 0 or n == 1:
     return 1
  if n in fib_cache:
     return fib_cache[n]
  ret = memo_fib(n - 1) + memo_fib(n - 2)
  fib_cache[n] = ret
  return ret

def dp_fib(n):
   partial_answers = [1, 1]
   while len(partial_answers) <= n:
     partial_answers.append(partial_answers[-1] + partial_answers[-2])
   return partial_answers[n]

print memo_fib(5), dp_fib(5)

我个人觉得记忆很自然。您可以采用递归函数并通过机械过程对其进行记忆(首先在缓存中查找答案,并在可能的情况下将其返回,否则以递归方式对其进行计算,然后在返回之前将计算结果保存在缓存中以备将来使用),而进行自下而上的操作动态编程要求您对计算解决方案的顺序进行编码,这样在它依赖的较小问题之前就不会计算“大问题”。


1
啊,现在我明白了“自上而下”和“自下而上”的含义;实际上,它仅指的是备忘录与DP。想想我是编辑问题的人,标题中提到了DP ...
ninjagecko 2011年

记忆fib v / s普通递归fib的运行时间是多少?
悉达多(Siddhartha)2012年

指数(2 ^ n)对于正常考克斯,我认为是一棵递归树。
悉达多

1
是的,它是线性的!我抽出了递归树,看到可以避免什么调用,并意识到对memo_fib(n-2)的调用将在第一次调用后全部避免,因此递归树的所有正确分支都将被切断,将减少为线性。
悉达多(Siddhartha)2012年

1
由于DP本质上涉及建立一个结果表,每个结果最多可以计算一次,因此可视化DP算法运行时的一种简单方法是查看表的大小。在这种情况下,它的大小为n(每个输入值一个结果),因此为O(n)。在其他情况下,它可以是n ^ 2矩阵,导致为O(n ^ 2)等
约翰逊黄

22

动态编程的一个关键特征是存在重叠子问题。也就是说,您要解决的问题可以分解为子问题,并且这些子问题中有许多共享子子问题。就像“分而治之”一样,但是您最终却做很多次相同的事情。自2003年以来,我在教或解释这些问题时使用了一个示例:您可以递归计算斐波那契数

def fib(n):
  if n < 2:
    return n
  return fib(n-1) + fib(n-2)

使用您喜欢的语言并尝试将其运行fib(50)。这将需要非常非常长的时间。大约和fib(50)自己一样多的时间!但是,正在做很多不必要的工作。fib(50)将调用fib(49)fib(48),但随后两个都将最终调用fib(47),即使值相同。实际上,fib(47)它将被计算三遍:通过的直接调用,从fib(49)的直接调用fib(48),以及从另一个的直接调用fib(48),这是通过计算fib(49)... 产生的。因此,您看到,我们存在重叠的子问题

好消息:无需多次计算相同的值。一旦计算了一次,就缓存结果,下次使用缓存的值!这是动态编程的本质。您可以将其称为“自上而下”,“记忆化”或其他任何想要的方式。这种方法非常直观并且易于实现。只需先编写一个递归解决方案,然后在小型测试中对其进行测试,添加备注(已计算值的缓存),然后---宾果游戏!---你做完了。

通常,您还可以编写一个等效的迭代程序,该程序从下至上运行,而无需递归。在这种情况下,这将是更自然的方法:从1到50循环计算所有斐波那契数。

fib[0] = 0
fib[1] = 1
for i in range(48):
  fib[i+2] = fib[i] + fib[i+1]

在任何有趣的情况下,自下而上的解决方案通常都很难理解。但是,一旦您了解了它,通常就可以清楚地了解算法的工作原理。实际上,在解决非平凡的问题时,我建议首先编写自上而下的方法,并在一些小示例上进行测试。然后编写自下而上的解决方案,并比较两者,以确保获得相同的结果。理想情况下,自动比较两个解决方案。编写一个小的例程,可以生成大量测试,理想情况下-所有达到一定大小的小型测试---并验证两种解决方案给出的结果相同。之后,在生产中使用自底向上的解决方案,但请保留自上而下的代码。这将使其他开发人员更容易理解您正在做的事情:自下而上的代码可能非常难以理解,即使您编写了代码,也即使您确切知道自己在做什么。

在许多应用程序中,由于递归调用的开销,自下而上的方法要快一些。堆栈溢出在某些问题中也可能是一个问题,请注意,这在很大程度上取决于输入数据。在某些情况下,如果您不太了解动态编程,则可能无法编写导致堆栈溢出的测试,但总有一天还是会发生。

现在,存在一些问题,其中自上而下的方法是唯一可行的解​​决方案,因为问题空间很大,不可能解决所有子问题。但是,“缓存”仍然可以在合理的时间内工作,因为您的输入只需要解决一部分子问题---但是要明确定义哪些子问题需要解决并写一个底端的问题太棘手。解决方案。另一方面,在某些情况下,您知道需要解决所有子问题。在这种情况下,请使用自下而上的方法。

我个人会使用自上而下的段落优化(又称自动换行)问题(查找Knuth-Plass换行算法;至少TeX使用它,而Adobe Systems的某些软件使用类似的方法)。我会使用自下而上的快速傅里叶变换


你好!!!我想确定以下命题是否正确。-对于动态编程算法,自底向上的所有值的计算比使用递归和记忆的渐近速度更快。-动态算法的时间始终为〇(Ρ),其中Ρ是子问题的数量。-NP中的每个问题都可以在指数时间内解决。
玛丽·星

关于上述主张我能说些什么?你有好主意吗?@osa
玛丽之星

@evinda,(1)总是错误的。它要么相同,要么渐近变慢(当不需要所有子问题时,递归可以更快)。只有可以解决O(1)中的每个子问题,(2)才是正确的。(3)是一种权利。NP中的每个问题都可以在非确定性机器上(例如量子计算机,可以同时完成多项任务:拥有它的蛋糕,同时吃掉它,并追踪两个结果)在多项式时间内解决。因此,从某种意义上说,NP中的每个问题都可以在常规计算机上以指数方式解决。提示:P中的所有内容也都在NP中。例如,加上两个整数
osa

19

让我们以斐波那契数列为例

1,1,2,3,5,8,13,21....

first number: 1
Second number: 1
Third Number: 2

换一种说法,

Bottom(first) number: 1
Top (Eighth) number on the given sequence: 21

如果是前五个斐波那契数

Bottom(first) number :1
Top (fifth) number: 5 

现在以递归斐波那契级数算法为例

public int rcursive(int n) {
    if ((n == 1) || (n == 2)) {
        return 1;
    } else {
        return rcursive(n - 1) + rcursive(n - 2);
    }
}

现在,如果我们使用以下命令执行该程序

rcursive(5);

如果我们仔细研究该算法,为了生成第五个数字,它需要第三个和第四个数字。所以我的递归实际上是从top(5)开始,然后一直到最低/最低数字。这种方法实际上是自上而下的方法。

为了避免多次进行相同的计算,我们使用动态编程技术。我们存储先前计算的值并重复使用。这种技术称为记忆。除了备注之外,动态编程还有更多内容,而无需讨论当前问题。

自顶向下

让我们重写我们的原始算法并添加记忆技术。

public int memoized(int n, int[] memo) {
    if (n <= 2) {
        return 1;
    } else if (memo[n] != -1) {
        return memo[n];
    } else {
        memo[n] = memoized(n - 1, memo) + memoized(n - 2, memo);
    }
    return memo[n];
}

然后执行以下方法

   int n = 5;
    int[] memo = new int[n + 1];
    Arrays.fill(memo, -1);
    memoized(n, memo);

该解决方案仍然是自顶向下的,因为算法是从最高值开始,然后从下至下进行每一步以获得我们的最高值。

自下而上

但是,问题是,我们可以从底部开始,例如从第一个斐波那契数开始,然后逐步上升。让我们使用这种技术重写它,

public int dp(int n) {
    int[] output = new int[n + 1];
    output[1] = 1;
    output[2] = 1;
    for (int i = 3; i <= n; i++) {
        output[i] = output[i - 1] + output[i - 2];
    }
    return output[n];
}

现在,如果我们研究此算法,则实际上是从较低的值开始,然后转到顶部。如果我需要第5个斐波那契数,我实际上是在计算第1个,那么从第2个再到第3个一直到第5个数字。这种技术实际上称为自下而上的技术。

最后两个,算法完全满足动态编程要求。但是一个是自上而下的,另一个是自下而上的。两种算法具有相似的时空复杂度。


我们可以说自下而上的方法通常是以非递归的方式实现的吗?
刘易斯·陈

不,您可以将任何循环逻辑转换为递归
Ashvin Sharma

3

动态编程通常被称为记忆化!

1,记忆化是自上而下的技术(通过分解来解决给定的问题)而动态编程是自下而上的技术(从琐碎的子问题开始解决给定的问题)

2.DP从基础案例开始寻找解决方案,然后向上解决。DP解决了所有子问题,因为它自下而上

与“记忆化”不同,“记忆化”仅解决所需的子问题

  1. DP具有将指数时间的蛮力解转换为多项式时间算法的潜力。

  2. DP可能会更有效,因为它是迭代的

相反,由于递归,记忆化必须支付(通常是很大的)开销。

更简单地说,“记忆化”使用自上而下的方法来解决问题,即从核心(主要)问题开始,然后将其分解为子问题,并类似地解决这些子问题。在这种方法中,相同的子问题可能会多次出现并消耗更多的CPU周期,因此会增加时间复杂度。而在动态编程中,相同的子问题不会被多次解决,但是先前的结果将用于优化解决方案。


4
事实并非如此,备忘录使用缓存可以帮助您将时间复杂度降低到与DP相同
InformedA 2014年

3

简单地说,自上而下的方法使用递归一次又一次地调用Sub问题,而
自下而上的方法使用单个而不调用任何一个问题,因此效率更高。


1

以下是自上而下基于DP的“编辑距离”问题的解决方案。我希望它也将有助于理解动态编程的世界:

public int minDistance(String word1, String word2) {//Standard dynamic programming puzzle.
         int m = word2.length();
            int n = word1.length();


     if(m == 0) // Cannot miss the corner cases !
                return n;
        if(n == 0)
            return m;
        int[][] DP = new int[n + 1][m + 1];

        for(int j =1 ; j <= m; j++) {
            DP[0][j] = j;
        }
        for(int i =1 ; i <= n; i++) {
            DP[i][0] = i;
        }

        for(int i =1 ; i <= n; i++) {
            for(int j =1 ; j <= m; j++) {
                if(word1.charAt(i - 1) == word2.charAt(j - 1))
                    DP[i][j] = DP[i-1][j-1];
                else
                DP[i][j] = Math.min(Math.min(DP[i-1][j], DP[i][j-1]), DP[i-1][j-1]) + 1; // Main idea is this.
            }
        }

        return DP[n][m];
}

您可以在家中考虑其递归实现。如果您以前没有解决过类似的问题,那将是非常不错的挑战。


1

自上而下:直到现在跟踪计算值,并在满足基本条件时返回结果。

int n = 5;
fibTopDown(1, 1, 2, n);

private int fibTopDown(int i, int j, int count, int n) {
    if (count > n) return 1;
    if (count == n) return i + j;
    return fibTopDown(j, i + j, count + 1, n);
}

自下而上:当前结果取决于其子问题的结果。

int n = 5;
fibBottomUp(n);

private int fibBottomUp(int n) {
    if (n <= 1) return 1;
    return fibBottomUp(n - 1) + fibBottomUp(n - 2);
}
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.