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

时间:2011-05-28 22:05:04

标签: dynamic-programming difference memoization

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

自上而下包括以“自然方式”解决问题,并检查您是否已经计算过子问题的解决方案。

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

8 个答案:

答案 0 :(得分:218)

答案 1 :(得分:65)

自上而下和自下而上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)

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

答案 2 :(得分:19)

动态编程的一个关键特性是存在重叠的子问题。也就是说,您尝试解决的问题可以分解为子问题,并且许多子问题共享子问题。这就像“分而治之”,但你最终会做很多次同样的事情。我自2003年以来在教授或解释这些问题时使用过的一个例子:你可以递归地计算Fibonacci numbers

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)的计算产生的那个...所以你看,我们有重叠的子问题

好消息:没有必要多次计算相同的值。计算一次后,缓存结果,下次使用缓存值!这是动态编程的本质。您可以将其称为“自上而下”,“备忘录”或其他任何您想要的内容。这种方法非常直观且易于实现。首先编写一个递归解决方案,在小测试中测试它,添加memoization(缓存已计算的值),然后--- bingo! ---你已经完成了。

通常你也可以编写一个从下到上工作的等效迭代程序,不需要递归。在这种情况下,这将是更自然的方法:从1到50循环计算所有斐波那契数字。

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

在任何有趣的场景中,自下而上的解决方案通常更难以理解。但是,一旦你理解了它,通常你会对算法的运作方式有一个更清晰的大局。在实践中,在解决重要问题时,我建议首先编写自上而下的方法并在小例子上进行测试。然后编写自下而上的解决方案,并比较两者,以确保你得到相同的东西。理想情况下,自动比较两种解决方案。编写一个可以生成大量测试的小例程 - 理想情况下 - 所有小到一定大小的测试 - 并验证两个解决方案都能给出相同的结果。之后在生产中使用自下而上的解决方案,但保留上下代码,注释掉。这将使其他开发人员更容易理解你正在做的事情:自下而上的代码可能是非常难以理解的,即使你写了它,即使你确切知道你在做什么。

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

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

我个人会使用自上而下的段落优化a.k.a Word wrap optimization problem(查找Knuth-Plass断行算法;至少TeX使用它,Adobe Systems的某些软件使用类似的方法)。我会自下而上使用Fast Fourier Transform

答案 3 :(得分:15)

让我们以斐波纳契系列为例

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 

现在让我们看一下递归Fibonacci系列算法作为例子

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

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

rcursive(5);

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

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

热门向下

让我们重写原始算法并添加memoized技术。

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个,然后是第二个,然后第三个一直到第5个数。这种技术实际上称为自下而上的技术。

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

答案 4 :(得分:4)

动态编程通常称为Memoization!

1.Memoization是自上而下的技术(通过分解来开始解决给定的问题)和动态编程是一种自下而上的技术(从琐碎的子问题开始解决,直到给定的问题)

2.DP通过从基本案例开始找到解决方案并向上工作。 DP解决了所有子问题,因为它是自下而上的

  

与Memoization不同,它只解决了所需的子问题

  1. DP有可能将指数时暴力解决方案转换为多项式时间算法。

  2. DP可能效率更高,因为它的迭代

  3.   

    相反,Memoization必须支付由递归引起的(通常很大的)开销。

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

答案 5 :(得分:3)

简单地说自上而下的方法使用递归来反复调用Sub问题,其中自下而上的方法使用单一而不调用任何一个,因此它更有效。

答案 6 :(得分: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];
}

您可以在家中考虑其递归实施。如果你以前没有解决过这样的问题,那就非常好并且充满挑战。

答案 7 :(得分: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);
}