从暴力递归到动态规划
概述
从代码角度, 动态规划的实现一般都是通过迭代完成的 (递推公式); 而一般迭代过程都有与之对应的递归写法;
这里默认动态规划都是基于迭代的实现. 实际上, 动态规划只是一种算法, 跟具体实现无关, 递归实现也是动态规划;
递归过程一般与人的直接思考过程更接近, 因此如果对递归结构比较熟悉, 就能更容易的写出相应解法;
动态规划的难点是递推过程 (经验和数学能力); 递归的难点是递归结构本身;
递归的一个问题是如果存在重复计算, 会大幅降低性能, 此时有两个解决方法:
转换为迭代结构; 转化过程可以套用固定模板, 详见示例;
使用记忆化搜索, 即保存中间结果; 如果用 python 书写, 可以使用
functools.lru_cache
装饰器, 详见示例;如何判断是否存在重复计算?
将递归过程展开为树状结构,当发现某些具有相同参数的递归调用出现在多个不同节点时,说明存在重复调用;
以斐波那契数列为例: f(5) f(4) f(3) f(3) f(2) ...
什么情况下可以使用记忆化搜索?
这个问题的本质, 实际上是判断问题本身是不是一个动态规划问题; 能用动态规划解决的问题必须具备无后效性, 只要具备这个特性, 就可以使用记忆化搜索;
无后效性: 某阶段的状态一旦确定, 则此后过程的决策不再受此前各种状态及决策的影响.
经典问题
原文总结了 4 种常见的递归过程,基本能覆盖大部分场景;
1. 自底向上的递归/迭代
最常见的递归模型, 一般过程:
确定初始状态 (递归基), 即 $k=0$ 时刻的状态)
假设已知 $k-1$ 及其之前时刻的状态,推导 $k$ 时刻的状态;
有时候可能是自顶向下, 本质是一样的;
示例 1: 跳台阶 (一维)
该模型下的一维问题几乎都是 "跳台阶" 问题的变体;
class Solution:
def climbStairs(self, n: int) -> int:
from functools import lru_cache
@lru_cache
def dfs(i): # 爬 i 阶存在的方法数
if i <= 1: return 1
# 爬 i 阶的方法数 = 爬 i - 1 阶的方法数 + 爬 i - 2 阶的方法数
return dfs(i - 1) + dfs(i - 2)
return dfs(n)
示例 2: 解码方法 (一维, "跳台阶" 变体)
有限制的 "跳台阶" 问题;
class Solution:
def numDecodings(self, s: str) -> int:
from functools import lru_cache
@lru_cache
def dfs(i): # s[0: i+1] 的解码方法数
# 最容易出错的点, 以 0 开头的字符串不存在相应的编码
if i <= 0: return int(s[0] != '0')
ret = 0
if '1' <= s[i] <= '9': # 如果 s[i] 在 0~9, 存在相应的编码
ret += dfs(i - 1) # s[i-1] == 1 和 s[i-2] 的特殊讨论
if s[i - 1] == '1' or s[i - 1] == '2' and '0' <= s[i] <= '6':
ret += dfs(i - 2)
return ret
return dfs(len(s) - 1)
示例 3: N 个骰子的点数 (一维, "跳台阶" 变体)
进阶版 "跳台阶", 相当于每次能跳的步数更多;
class Solution:
def dicesProbability(self, n: int) -> List[float]:
def dfs(k):
if k == 1:
return [1] * 7
dp_pre = dfs(k - 1)
dp = [0] * (k * 6 + 1)
for i in range(1 * (n - 1), 6 * (n - 1) + 1): # n - 1 个骰子的点数范围
for d in range(1, 7): # 当前骰子掷出的点数 (跳的台阶数)
dp[i + d] += dp_pre[i]
return dp
dp = dfs(n)
return [x / (6 ** n) for x in dp[n:]]
2. 多样本位置全对应的尝试模型
3. 范围内的尝试模型
4. 寻找业务限制的尝试模型
该类型原文只给了很难的几个例子, 感兴趣的可以去看原视频;
更多相关问题
algorithms/暴力递归与动态规划
参考资料
Last updated