避免在递归中重复计算

  • ​​前情提要​​
  • ​​重叠子问题​​
  • ​​使用备忘录解决重复计算​​
  • ​​重叠子问题处理模式​​
  • ​​重叠子问题的限制​​
  • ​​方案弊端​​
  • ​​总结与升华​​

前情提要

在上一篇文章(​​从贪心算法到暴力递归法——从局部最优到整体最优​​),我们提到了最优解问题,在考虑整体最优的情况下,需要找到一种办法获取最优解。

那么最简单直接的做法其实就是把所有可行的答案穷举出来,然后在所有可行的答案中找出满足条件的最值。这样的解法看似“天衣无缝”,但它有着重要的缺陷——执行效率极低。

导致这个问题的罪魁祸首是重叠子问题,那么应该如何解决重叠子问题并提高算法效率呢?

重叠子问题

斐波那契数列没有求最值的问题,因此严格来说它不是最优解问题,当然也就不是动态规划问题。但它能帮助你理解什么是重叠子问题。首先,它的数学形式即递归表达是这样的:

解决递归中的重复计算问题——保存临时的中间结果_python


它的代码如下所示:

def Fibonacci(n):
if (n == 0 or n == 1):
return n
if (n > 1):
return Fibonacci(n - 1) + Fibonacci(n - 2)

def main():
result = Fibonacci(4)
print(result)

if __name__ == "__main__":
main()

这个代码本身没有问题,但是它效率极低。假设上面的函数调用输入是 10,把递归树画出来:

解决递归中的重复计算问题——保存临时的中间结果_重叠子问题_02


如果要计算原问题 F(10),就需要先计算出子问题 F(9) 和 F(8),如果要计算 F(9),就需要先计算出子问题 F(8) 和 F(7),以此类推。这个递归的终止条件是当 F(1)=1 或 F(0)=0 时结束。

看完斐波那契数列的求解树之后,你发现问题没有:

  • 用红色标出的两个区域中,它们的递归计算过程完全相同!

这意味着,第 2 个红色区域的计算是“完全没有必要的”,它是重复的计算。因为我们已经在求解 F(7) 的时候把 F(6) 的所有情况计算过了。因此我们把第 2 个红色区域的计算称为重叠子问题

使用备忘录解决重复计算

既然存在重复的子问题,那我们在遇到这些重复的子问题时,只需要执行一次即可,即消灭重复计算的过程

我们可以创建一个备忘录(memorization),在每次计算出某个子问题的答案后,将这个临时的中间结果记录到备忘录里,然后再返回。

接着,每当遇到一个子问题时,我们不是按照原有的思路开始对子问题进行递归求解,而是先去这个备忘录中查询一下。如果发现之前已经解决过这个子问题了,那么就直接把答案取出来复用,没有必要再递归下去耗时的计算了。

对于备忘录,可以考虑使用以下两种数据结构:

  • 数组(Array),通常对于简单的问题来说,使用一维数组就足够了。
  • 哈希表(Hash table),如果你存储的状态不能直接通过索引找到需要的值(比如斐波那契数列问题,你可以直接通过数组的索引确定其对应子问题的解是否存在,如果存在你就拿出来直接使用),比如你使用了更高级的数据结构而非简单的数字索引,那么你还可以考虑使用哈希表,即字典来存储中间状态,来避免重复计算的问题。

下面看看用数组实现的备忘录来解决斐波那契数列的代码:

def Fibonacci(n, memo):
if (n == 0 or n == 1):
return n
# 如果备忘录中找到了之前计算的结果,那就直接返回,避免重复计算
if (memo[n] != None):
return memo[n]
if (n > 1):
memo[n] = Fibonacci(n - 1, memo) + Fibonacci(n - 2, memo)
return memo[n]
# 如果数值无效(比如 < 0),则返回0
return 0

def FibonacciAdvance(n):
memo = [None] * (n + 1)
return Fibonacci(n, memo)

def main():
result = FibonacciAdvance(4)
print(result)

if __name__ == "__main__":
main()

实际上,这就是我们所熟知的“剪枝与优化”,把一棵存在巨量冗余的递归树通过剪枝,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。通过这种方式,我们大幅缩减了算法的计算量,因为所有重复的部分都被跳过了。

重叠子问题处理模式

假设问题是这样的:当目标为 x,其中 x 可能是一个任意长度的向量,目标可能包含多个元素,求最优解 F(x)。

举个例子,比如在硬币这个问题里,x 就是硬币总额度,F(x) 就是最少的硬币数量。同时,我们还需要知道问题是求最小值还是最大值,并以此来定义我们的数值函数 G(t):

  • 如果求最小值,那么 G() 是 min()
  • 如果求最大值,那么 G() 就是 max()

除此之外,我们还需要通过当前的问题获得后续的一系列子问题,假定当前得到子问题的参数为 c,得到后续子问题的函数是 S,那么这个函数就是 S(x, c)。接着,我们就可以用 F(S(x, c)) 来求得子问题的结果。

我们再定义一个函数 V(x),该函数可以聚合当前参数 c 和当前子问题的结果。

最后,我们还要定义每一步如何与子问题进行叠加。定义一个叠加函数 H(x)。

综上所述,最后得到如下求解公式:

  • 解决递归中的重复计算问题——保存临时的中间结果_python_03

因此,当你解决类似问题时,只需要把问题套用到上面的公式(框架)中,就能用一个递归函数来描述所有的问题。你可以尝试把斐波那契数列和硬币问题分别套入这个模型,就知道后面的问题定义该怎么举一反三了。

在定义好问题后,你就可以编写基于递归算法的代码了。不过需要注意,上面的公式并不包含边界值的处理。所谓的边界值就是无法再分解为子问题的子问题。

比如在硬币找零问题中,x 为 0 的时候就是一个所谓的边界值。只要处理好递归函数和边界值,我们就能一通百通了。

重叠子问题的限制

有些问题虽然看起来像包含“重叠子问题”的子问题,但是这类子问题可能具有后效性,但我们追求的是无后效性。

所谓无后效性,指的是在通过 A 阶段的子问题推导 B 阶段的子问题的时候,我们不需要回过头去再根据 B 阶段的子问题重新推导 A 阶段的子问题,即子问题之间的依赖是单向性的

换句话说,如果一个问题可以通过重叠子问题缓存进行优化,那么它肯定都能被画成一棵树。

方案弊端

通过重叠子问题缓存可以极大加速我们的代码执行效率。但是凡事都有两面性,毋庸置疑,这种方案肯定是通过某种牺牲换取了性能的提升。

在硬币找零问题中,我们就可以利用备忘录来避免重复计算。但这样有个问题,如果我们的钱币总额数量非常巨大,那这个数组的大小就会非常巨大,导致的结果就是会占据大量的内存存储空间,而且有很多的数字其实是不会被求解的,存在很多的“存储空洞”。显然,这是一种浪费。

所以在解题的过程中,你需要根据实际情况,在空间和时间中寻求一个平衡,将这个问题考虑在内。

总结与升华

备忘录的思想极为重要,特别是当求解的问题包含重叠子问题时,只要面试的问题包含重复计算,你就应该考虑使用备忘录来对算法时间复杂度进行简化。具体来说,备忘录解法可以归纳为:

  1. 用数组或哈希表来缓存已解的子问题答案,并使用自顶向下的递归顺序递归数据;
  2. 基于递归实现,与暴力递归的区别在于备忘录为每个求解过的子问题建立了备忘录(缓存);
  3. 为每个子问题的初始记录存入一个特殊的值,表示该子问题尚未求解;
  4. 在求解过程中,从备忘录中查询。如果未找到或是特殊值,表示未求解;否则取出该子问题的答案,直接返回。

含有备忘录的递归算法已经与动态规划思想十分相似了,从效率上说也是如此。

备忘录让我们实现了对算法时间复杂度的“降维打击”,这与贪心算法到递归的进步程度不同,这是真正意义上的动态规划思维:

  • 我们考虑了整体最优;
  • 在计算的过程中保存计算当中的状态,并在后续的计算中复用之前保存的状态。