【问题标题】:Memoizing exponential algorithms记忆指数算法
【发布时间】:2017-02-26 22:32:50
【问题描述】:

在识别指数运行时间时有一些特定的模式。例如,如果在数组中的每个元素处,指针可以进行​​一、二或三步,我们在记忆化之前查看 O(3^N) 数组,因为每个元素有三个函数调用。

但是,在记忆化之后识别运行时间背后的模式是什么,我有点困惑。一般来说,有什么关系?我知道记忆在做什么——只是摆脱重复的子调用,但在面试环境中,我不想画出一棵树并划掉所有重复的子调用来直观地了解运行时。有什么想法吗?

编辑:

例如,下面的问题蛮力是 O(3^N),记忆后是 O(n^3),我不知道如何直觉,或者我是否缺少潜在的模式。

一只青蛙正在过河。河流被分成 x 个单位,在 那里的每个单元可能存在也可能不存在石头。青蛙可以跳上 石头,但不能跳入水中。

给定一个按升序排列的石头位置列表(以单位为单位) 命令,确定青蛙是否能够通过登陆来过河 最后一块石头。最初,青蛙在第一块石头上并假设 第一次跳跃必须是 1 个单位。

如果青蛙的最后一次跳跃是 k 个单位,那么它的下一次跳跃必须是 k - 1、k 或 k + 1 个单位。请注意,青蛙只能在 前进方向

方法 #1 蛮力 [超过时间限制]

In the brute force approach, we make use of a recursive function canCrosscanCross which takes the given stone array, the current position and the current jumpsize as input arguments. We start with currentPosition=0 and jumpsize=0. Then for every function call, we start from the currentPosition and check if there lies a stone at (currentPostion + newjumpsize), where, the newjumpsize could be jumpsize, jumpsize+1 or jumpsize-1. In order to check whether a stone exists at the specified positions, we check the elements of the array in a linear manner. If a stone exists at any of these positions, we call the recursive function again with the same stone array, the currentPosition and the newjumpsize as the parameters. If we are able to reach the end of the stone array through any of these calls, we return true to indicate the possibility of reaching the end.

Java

public class Solution {
    public boolean canCross(int[] stones) {
        return can_Cross(stones, 0, 0);
    }
    public boolean can_Cross(int[] stones, int ind, int jumpsize) {
        for (int i = ind + 1; i < stones.length; i++) {
            int gap = stones[i] - stones[ind];
            if (gap >= jumpsize - 1 && gap <= jumpsize + 1) {
                if (can_Cross(stones, i, gap)) {
                    return true;
                }
            }
        }
        return ind == stones.length - 1;
    }
}
Complexity Analysis

Time complexity : O(3^n)

​​Recursion tree can grow upto 3^n
​
Space complexity : O(n). Recursion of depth n is used.

记忆后:

public class Solution {
    public boolean canCross(int[] stones) {
        int[][] memo = new int[stones.length][stones.length];
        for (int[] row : memo) {
            Arrays.fill(row, -1);
        }
        return can_Cross(stones, 0, 0, memo) == 1;
    }
    public int can_Cross(int[] stones, int ind, int jumpsize, int[][] memo) {
        if (memo[ind][jumpsize] >= 0) {
            return memo[ind][jumpsize];
        }
        for (int i = ind + 1; i < stones.length; i++) {
            int gap = stones[i] - stones[ind];
            if (gap >= jumpsize - 1 && gap <= jumpsize + 1) {
                if (can_Cross(stones, i, gap, memo) == 1) {
                    memo[ind][gap] = 1;
                    return 1;
                }
            }
        }
        memo[ind][jumpsize] = (ind == stones.length - 1) ? 1 : 0;
        return memo[ind][jumpsize];
    }
}

复杂性分析

Time complexity : O(n^3)
​​Memorization will reduce time complexity to O(n^3).

Space complexity : O(n^2)
​​ memo matrix of size n^2 is used.

【问题讨论】:

    标签: algorithm time-complexity memoization


    【解决方案1】:

    所以,我认为如果你举一个具体的例子会很有帮助,这样我就能确切地知道你在说什么。但是,我想我可以猜到。 :)

    从渐近分析的角度来看,如果记忆化意味着您可以对最终发生的子调用数量设置一个小得多的界限,那么记忆化“可证明”会有所帮助。

    经典示例:斐波那契数。

    假设你有这样一个简单的实现:

    int fib(int n) {
      if (n < 2) {
        return 1;
      } else {
        return fib(n-2) + fib(n-1);
      }
    }
    

    每次发生调用时,都会进行两次子调用,并且参数仅以恒定的量减少。所以,你应该可以说运行时是2^{O(n)}

    (更多细节:当我调用fib(n),并在每个分支选择一个子分支直到触底时,至少有n/2 分支发生。所以至少是2^{n/2}。实际上,“通常”,意思是,如果我随机选择其中一个分支,n 平均会缩小 1.5。所以有更多类似 2^{2n/3} 的子调用。而且,不超过 2^n。)

    当你记住它时会发生什么?

    这意味着您创建一个长度为n 的缓冲区,并在那里缓存任何子调用的结果。 fib 每次计算前都需要检查缓存。

    关键是,现在最多有n 子调用起作用,而不是2^n 子调用起作用。我们进行子调用而不立即从缓存中提取结果的次数最多是......缓存的大小。

    这与动态规划类似。当您为旧表分配并递归计算值时,运行时间基本上就是表的大小。

    因此,在 fib 的情况下,您应该得到指数级的改进。运行时间类似于 n 而不是 2^n

    如何正式地说?假设我有这样的(假的)代码:

    std::vector<boost::optional<int>> cache;
    
    int fib(int n) {
      if (n < 2) {
        return 1;
      } else if (cache[n]) {
        return *cache[n];
      } else {
        cache[n] = fib(n-2) + fib(n-1);
        return *cache[n];
      }
    }
    

    fib(n) 运行需要多长时间?

    第一点:第三个分支,其中的值尚未缓存,对于n 的每个值只能发生一次。 (因为在任何后续运行中,它都会被缓存。)

    如下:对于任意 m fib(m) 最多被调用两次,一次来自fib(n-2) 子调用(在计算fib(m+2) 时),一次来自fib(n-1) 子调用(当@987654342 @ 正在计算中)。这两种情况是唯一可以直接调用fib(m)的情况,而且它们都只调用一次。

    因此,对 fib 的调用总数为 2 * n。我们执行的加法次数是n,因为我们对必须填充的每个缓存成员只执行一次加法。

    因此运行时间将类似于n 乘以加法成本,加上n 乘以表查找成本,而2^n 乘以加法成本。

    我猜在面试的情况下,您会将加法和查表视为单位成本操作。如果你真的想要代码可以计算任意大整数的fib,你不能使用int,你需要使用大整数,并且大小大约是log n位。然后,我想,加法就像O(log n)。所以我认为你会渐近地得到n log n

    【讨论】:

    • 赞成——但我添加了一个我很困惑的时间复杂度分析关系的例子。我熟悉 fib 示例,但对这种模式如何应用于 O(3^N) O(4^N) 等复杂性更感兴趣。您关于时间复杂度本质上是缓存大小的观点非常有启发性不过。
    • 嗯所以,概括我写的,我期望的是,如果分支因子是b,大小是N,那么递归调用的总数应该最多b * N。我的意思是,如果你把它想象成一个图,其中点是函数调用,边是子调用,有N 点,出度是b。如果除了进行子调用之外,每个节点还必须做w 的工作量,那么我想说,所有时间都应该像N * (b + w) 或类似的东西。 (我假设表查找是 O(1))这有点挥手,但希望你明白我在说什么。
    猜你喜欢
    • 2013-01-08
    • 2018-04-19
    • 2014-02-11
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-02-04
    • 2015-06-16
    相关资源
    最近更新 更多