动态规划算法
-
动态规划问题的一般形式就是求最值:
- 动态规划是运筹学的一种最优化方法
-
动态规划的应用场景:
- 求最长递增子序列
- 求最小编辑距离
- 动态规划的核心问题:
- 穷举
- 因为要求最值,肯定要将所有可行的答案穷举出来,然后在其中找最值
-
动态规划的穷举很特殊:
-
存在重叠子问题:
- 如果暴力穷举效率低下
- 需要使用备忘录或者DP Table来优化穷举过程,避免不必要的计算
- 具备最优子结构: 这样才能通过子问题的最值找到原问题的最值
- 列出正确的状态转移方程才能正确地穷举: 因为穷举出所有可行解并不是一件容易的事
-
存在重叠子问题:
-
动态规划三要素:
- 重叠子问题
- 最优子结构
- 状态转移方程
- 状态转移方程思维框架:
- 明确状态
- 定义DP数组或者函数的含义
- 明确选择
- 明确base case
斐波那契数列
直接递归
- 斐波那契数列数列的数学形式就是递归的,代码如下:
int fib(int N) {
if (N == 1 || N == 2) {
return 1;
}
return fib(N-1) + fib(N-2);
}
这是最简单易懂的直接递归的方法,同时也是效率最低的方法,可以通过画出递归树看出
- 递归的问题分析,最好都画出递归树,方便分析算法的复杂度,寻找算法低效的原因
- 递归算法的时间复杂度 = 子问题个数 * 解决一个子问题需要的时间
- 当N=20时,斐波那契数列递归树分析:
- 要计算f(20),就要计算出f(19)和f(18),然后要计算f(19),就要计算出f(18)和f(17),以此类推.最后到f(2)和f(1)的时候,结果已知.此时递归树结束
- 根据递归算法时间复杂度等于子问题个数乘以解决一个子问题需要的时间:
- 子问题个数: 也就是递归树中节点的总数.因为二叉树的节点总数为指数级别,所有子问题的个数为O(2N)
- 解决一个子问题需要的时间: 因为这个算法中没有循环,只有f(n-1)+f(n-2)一个加法操作,所有时间为O(1)
- 直接递归算法时间复杂度: O(2n),为指数级别.相当复杂
- 观察递归树,分析出算法低效的原因:
- 存在大量的重复计算
- f(18)被计算了两次,以f(18)为根的递归树体量巨大,多算一遍,就会耗费大量时间
- 而且,不止f(18)这一个节点会被重复计算,进而导致算法低下
- 这个问题就是动态规划的第一个性质 : 重叠子问题
带备忘录的递归算法
-
重叠子问题解决:
- 因为耗时的原因是因为重复计算
- 那么可以创建一个备忘录:
- 每次算出某个子问题的答案先记录到备忘录中再返回
- 每次遇到一个子问题先去备忘录中查询
- 如果发现备忘录中已经存在这个解决过的问题,直接获取答案,不需要再耗时计算了
- 通常使用数组作为备忘录, 也可以使用Hash表作为备忘录:
int fib(int N) {
if (N < 1) {
return 0;
}
// 备忘录初始化为0
vector<int> memo(N+1, 0);
// 初始化最简情况
return helper(memo, N);
}
int helper(vector<int>& memo, int n) {
if (n == 1 || n == 2) {
return 1;
}
// 已经计算过的直接返回
if (memo[n] != 0) {
return memo[n];
}
memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
return memo[n];
}
- 带备忘录的递归算法的递归树分析:
- 带备忘录的递归树算法是将一棵存在巨量冗余的递归树通过剪枝, 改造成了一幅不存在冗余的递归图,极大减少了子问题,即递归树中节点的个数
- 根据递归算法时间复杂度等于子问题个数乘以解决一个子问题需要的时间:
- 子问题个数: 即递归树中节点的总数.由于不存在冗余计算,子问题就是f(1),f(2)...f(20),数量和输入的规模n成正比,所以子问题的个数就是O(n)
- 解决一个子问题需要的时间: 因为这个算法中没有没有循环,只有加法操作,所以时间为O(1)
- 带备忘录的递归算法时间复杂度: O(n).比直接递归算法高效得多
-
带备忘录的递归解法的效率和迭代的动态规划解法一样:
-
带备忘录的递归解法: 自顶向下
- 从上向下延伸
- 从一个规模较大的源问题,向下逐渐分解问题规模,直到触底.然后逐层返回答案
-
迭代的动态规划解法: 自底向上
- 直接从问题规模最小的最底层开始往上推,直到推导出需要的答案.这就是动态规划的思路
- 动态规划一般都脱离了递归,去采用循环迭代完成计算
-
带备忘录的递归解法: 自顶向下
DP数组的迭代算法
- 在备忘录的基础上,将备忘录独立出来成为一张表,叫作DP Table
- 在DP Table上完成自底向上的推算:
int fib(int N) {
vector<int> dp(N+1, 0);
dp[1] = dp[2] = 1;
for (int i = 3; i <= N; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[N];
}
DP Table和备忘录的递归树剪枝后的结构很相似,只不过是反过来计算而已.实际上,带备忘录的递归解法中的备忘录, 最终完成的就是这个DP Table. 所以带备忘录的递归算法和DP数组的迭代算法在大部分情况下的算法效率是相同的
-
状态转移方程: 一种描述问题结构的数学形式
- 状态转移: 比如想要将f(n)做成一个状态n,这个状态n是由状态n-1和状态n-2相加转移而来
- 状态转义方程是解决问题的核心
- 状态转移方程直接代表着暴力解法
- 动态规划最困难的就是写出状态转移方程
斐波那契数列解法补充
- 根据斐波那契数列的状态转移方程:
- 当前状态只和之前的两个状态有关
- 所以并不需要一个很长的DP Table来存储所有状态
- 只要存储之间的两个状态就可以
- 可以将空间复杂度优化为O(1)
int fib(int n) {
if (n == 1 || n ==2) {
return 1;
}
int prev = 1, curr = 1;
for (int i = 3; i <= n; i++) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}
凑零钱问题
-
题目:
- 给k种面值的硬币,硬币面值分别为c1, c2,...ck. 每种硬币的数量无限,需要凑够总金额amount. 最少需要几枚硬币凑出这个金额,如果不能凑出则返回 -1
- 对于计算机来说,解决这个问题的方法就是将所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币
直接递归
- 要符合最优子结构,子问题之间必须互相独立
-
凑零钱问题分析:
- 这个问题是动态规划问题,因为具有最优子结构
- 原问题: 比如想要求amount=11时的最少硬币数
- 子问题: 如果知道凑出amount=10的最少硬币数
- 只需要将子问题的答案加1. 再选一枚面值为1的硬币后就是原问题的答案
- 因为硬币的数量是没有限制的,子问题之间没有限制,是相互独立的
- 这个问题是动态规划问题,因为具有最优子结构
-
确定是动态规划问题后,就要思考如何列出状态转移方程:
-
先确定状态:
- 状态就是原问题和子问题中变化的数量
- 由于硬币数量是无限的,所以唯一的状态就是目标金额amount
-
然后确定DP函数的定义:
- 当前的目标金额是n,至少需要dp(n)个硬币凑出该金额
-
然后确定选择并择优:
- 也就是对于每个状态,可以做出什么选择改变当前状态
- 无论当前的目标金额是多少,选择就是从面额列表coins中选出一个硬币,然后目标金额就会减少
- 伪码如下:
def coinChange(coins: List[int], amount: int): # 定义: 要凑出金额n,至少需要dp(n)个硬币 def dep(n): # 做选择: 选择需要硬币最少的那个结果 for coin in coins: res = min(res, 1 + dp(n - coin)) return res; # 计算出要求的问题dp(amount) return dp(amount)
-
最后明确base case:
- 明确初始的已知状态
- 目标金额为0时,所需硬币数量为0.当目标金额小于0时,无解.返回-1
-
先确定状态:
def coinChange(coins: List[int], amount: int):
def dp(n):
if n==0: return 0
if n < 0: return -1
# 求最小值,所以初始化为正无穷
res = float('INF')
for coin in coins:
subproblem = dp(n - coins)
if subproblem == -1: continue
res = min(res, 1 + subproblem)
return res if res != float('INF') else -1
return dp(amount);
- 以上代码的数学形式就是通过直接递归获取到的状态转移方程
-
消除重叠子问题:
- 直接递归算法的递归树分析:
- 根据时间复杂度等于子问题总数乘以每个子问题的时间:
- 子问题总数: 这个问题的子问题具体个数比较难看出,可以分析为O(nk),为指数级别的个数
- 每个子问题的时间: 每个子问题含有一个for循环,所以时间复杂度为O(k)
- 算法时间复杂度: O(k * nk).是指数级别
- 根据时间复杂度等于子问题总数乘以每个子问题的时间:
- 直接递归算法的递归树分析:
带备忘录的递归算法
- 可以通过备忘录消除重叠子问题:
def coinChanges(coins: List[int], amount: int):
# 备忘录
memo = dict()
def dp(n):
# 查看备忘录,避免重复计算
if n in memo: return memo[n]
# 如果备忘录中不存在,则进行计算
if n = 0: return 0
if n < 0: return -1
# 求最小值,所以初始化为正无穷
res = float('INF')
for coin in coins:
subproblem = dep(n - coin)
if subproblem == -1: continue
res = min(res, 1 + subproblem)
# 将计算结果记入备忘录
memo[n] = res if res != float('INF') else -1
return memo[n]
return dep(amount)
- 带备忘录的递归算法的递归树分析:
- 根据算法时间复杂度等于子问题总数乘以每个子问题的时间
- 子问题总数: 由于引用了备忘录,大大减少了子问题数目,完全消除了子问题的冗余,所以子问题的总数不会超过n,即子问题的数目为O(n)
- 每个子问题的时间: 由于存在for循环,每个子问题的处理时间为O(k)
- 算法时间复杂度: O(kn).大大提高了算法效率
- 根据算法时间复杂度等于子问题总数乘以每个子问题的时间
DP数组的迭代算法
- 自底向上使用DP Table来消除重叠子问题
int coinChanges(vector<int> coins, int amount) {
// 数组大小为amount+1,初始值也为amount+1
vector<int> dp(amount + 1, amount + 1);
/*
* dp[i] = x:
* 当目标金额为i时,至少需要x枚硬币
*/
for (int i = 0; i < dp.size(); i++) {
// 内层for求所有子问题+1的最小值
for (coin in coin) {
// 如果子问题无解,则跳过循环
if (i - coin < 0) {
continue;
}
dp[i] = min(dp[i], 1 + dp[i - coin])
}
}
return (dp[amount] == dp[amount + 1]) ? -1 : dp[amount];
}
- 数组之所以初始化为amount+1. 是因为凑成amount金额的硬币数最多只可能等于amount, 全用面值为1元面值的硬币时
- 初始化为amount+1就相当于初始化为正无穷, 以便后续取最小值
总结
-
斐波那契问题:
- 解释了如何通过备忘录或者DP Table的方法来优化递归树
- 明确了这两种方法本质上是一样的,只是自顶向下(备忘录) 和自底向上(DP Table) 的不同
-
凑零钱问题:
- 展示了如何流程化确定状态转移方程
- 只要通过状态转义方程写出直接递归的算法,然后就可以通过优化递归树,消除重叠子问题
-
计算机解决问题的唯一的解决办法就是穷举:
- 穷举所有可能性
- 算法设计就是先思考如何穷举
- 然后再追求优化穷举的方式
-
如何穷举: 列出动态转移方程
- 列出动态转移方程就是在解决如何穷举的问题.之所以列出动态转移方程困难是因为:
- 很多穷举需要递归实现
- 有的问题本身的解空间复杂,不容易穷举完整
-
优化穷举的方式: 备忘录和DP Table
- 备忘录和DP Table就是在追求优化穷举的方式
- 运用的是用空间换时间的思路来降低算法时间复杂度