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

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,把递归树画出来:

如果要计算原问题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()
实际上,这就是我们所熟知的“剪枝与优化”,把一棵存在巨量冗余的递归树通过剪枝,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。通过这种方式,我们大幅缩减了算法的计算量,因为所有重复的部分都被跳过了
三、重叠子问题的限制
有些问题虽然看起来像包含“重叠子问题”的子问题,但是这类子问题可能具有后效性,但我们追求的是无后效性。
所谓无后效性,指的是在通过 A 阶段的子问题推导 B 阶段的子问题的时候,我们不需要回过头去再根据 B 阶段的子问题重新推导 A 阶段的子问题,即子问题之间的依赖是单向性的。
换句话说,如果一个问题可以通过重叠子问题缓存进行优化,那么它肯定都能被画成一棵树。
四、方法的弊端
通过重叠子问题缓存可以极大加速我们的代码执行效率。但是凡事都有两面性,毋庸置疑,这种方案肯定是通过某种牺牲换取了性能的提升。
在硬币找零问题中,我们就可以利用备忘录来避免重复计算。但这样有个问题,如果我们的钱币总额数量非常巨大,那这个数组的大小就会非常巨大,导致的结果就是会占据大量的内存存储空间,而且有很多的数字其实是不会被求解的,存在很多的“存储空洞”。显然,这是一种浪费。
所以在解题的过程中,你需要根据实际情况,在空间和时间中寻求一个平衡,将这个问题考虑在内。
五、总结与升华
备忘录的思想极为重要,特别是当求解的问题包含重叠子问题时,只要问题包含重复计算,你就应该考虑使用备忘录来对算法时间复杂度进行简化。具体来说,备忘录解法可以归纳为:
1.用数组或哈希表来缓存已解的子问题答案,并使用自顶向下的递归顺序递归数据;
2.基于递归实现,与暴力递归的区别在于备忘录为每个求解过的子问题建立了备忘录(缓存);
3.为每个子问题的初始记录存入一个特殊的值,表示该子问题尚未求解;
4.在求解过程中,从备忘录中查询。如果未找到或是特殊值,表示未求解;否则取出该子问题的答案,直接返回。
4630



被折叠的 条评论
为什么被折叠?



