很久没看算法,最近复习了一下动态规划,结合网上的很多讲解进行一下梳理和总结。
动态规划(dynamic programming)
1. 概念
每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,这就是动态的含义。这种多阶段最优化决策解决问题的过程就称为动态规划。
简单来说,就是将一个问题拆成更小规模的子问题集,求解这些子问题,找到不同规模的子问题之间的推演规律,即可从最小规模的情形推断出大问题的解。
2. 适用的问题
如何确定一个问题能否使用DP来解决?需要满足两个条件:
- 可以将大问题拆分成多个小问题
- 这种拆分具有无后效性、最优子结构的性质
阶段&状态
-
每个阶段只有一个状态->递推
比如:斐波那契数列 已知f(n)=f(n-1)+f(n-2)以及初始的f(1)和f(2),只要从f(3)逐个计算得到f(n)即可 -
每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心
如,找到一个集合中最小的n个数,一定是由最小的n-1个数加上其余数中最小的一个数得到 -
每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;
可能需要知道具体的状态组成。 -
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划 。
无后效性:一旦f(n)确定,f(n)的生成过程就不再重要,对之后的问题没有影响。即:给定某一阶段的状态,则在这一阶段以后过程的发展不受这阶段以前各段状态的影响。
最优子结构:大问题的最优解可以由小问题的最优解推出。
3. 思想与策略
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题,按顺序求解子阶段,前一子问题的解为后一子问题的求解提供了有用的信息。在求解任一子问题时列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每个子问题只解一次。
基本思路:空间换时间,保存子问题的计算结果。为什么会快呢,因为:自带剪枝。多项式级的解空间。
重点在拆解,然后其实就是,聪明的枚举,机智的暴力。
步骤:
0. 定义数组元素的含义(重要!)---> 我是谁?
- 寻找状态转移方程(数组元素间的关系式)--->我从哪儿来?到哪儿去?
- 利用状态转移方程自底向上求解问题(找出初始值)
状态转移方程:用来描述问题结构的数学形式。动态规划的思想类似于数学归纳法的思想。我们需要得到规模为n时问题的答案f(n),那么,如果我们能够从规模为k-1时的f(k-1)推出规模为k时问题的答案f(k),我们就一定可以根据最小规模时的答案推出最终的f(n)。而状态转移方程,就是用来描述k-1状态和k状态之间的递推关系的,类似于通项公式。
自底向上:我们并不是从最终需要解答的问题来倒推子问题,而是从最小规模子问题逐步计算,得到最终的大规模问题的解。画一下递归图,就会比较清楚地理解这个概念。
难点:比较难的是找到状态转移方程,需要仔细考虑第N项和前若干项之间的关系。
一个可行的考虑思路:暴力递归解法->带备忘录的递归解法->非递归的动态规划解法
4. 例子
4.1 斐波那契数列
一个可能采用的解法:递归
int fib(int N){
if( N == 1 || N == 2 ){
return 1;
}
return fib(N-1) + fib(N-2);
}
但是包含大量重复计算。复杂度O(2^N)
带了备忘录的递归解法:仍然递归计算,但是使用外部变量保存中间计算值。递归过程中,需要的值如果已经被计算过,就不再进行计算, 直接使用上次计算的结果。
int Fib(int N) {
if(N<1) return 0;
vector<int> memo(N+1,0);
memo[1] = 1;
memo[2] = 1;
return CalculateFib(memo, N);
}
int CalculateFib(vector<int>& memo, int n){
if( n > 0 && memo[n] == 0)
memo[n] = CalculateFib(memo, n - 1) + CalculateFib(memo, n - 2);
return memo[n];
}
分析一下memo这个数组,memo[i]的值其实就是斐波那契数列第i个元素的值。该方法从我们需要的解答出发,去计算最终解答所需要的之前阶段的状态,但是通过存储memo[i],避免了重复的计算。
那么,如果我们自己主动计算出从1开始的各个memo[i],我们同样可以获得memo[n]的值。DP所做的就是这件事。
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];
}
其实是一种很暴力的解法,可是暴力它不香吗。
4.2 凑零钱问题
给你k种面值的硬币,面值分别为c1,c2,...,ck,再给出总金额n,问,需要最少几枚金币凑出这个金额,如果不可能凑出则回答-1。
n = 0时,f(n)=0
其他情况下,f(n)=1+min{f(n-ci)|i属于[1,k]}
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int i = 0; i < dp.size(); i++) {
// 内层 for 在求所有子问题 + 1 的最小值
for (int coin : coins) {
if (i - coin < 0) continue;
dp[i] = min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
4.3 跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法?
dp[i]是什么,是青蛙跳上一个i级的台阶一共有dp[i]种跳法。
青蛙在i级的台阶上,意味着青蛙从i-1或i-2的台阶上直接跳上来,这两种跳法是不同的,因此是相加的关系,即dp[i]=dp[i-1]+dp[i-2]。
int Jump( int n ){
if(n <= 1)
return n;
// 先创建一个数组来保存历史数据
int[] dp = new int[n+1];
// 给出初始值
dp[0] = 0;
dp[1] = 1;
dp[2] = 2;
// 通过关系式来计算出 dp[n]
for(int i = 3; i <= n; i++){
dp[i] = dp[i-1] + dp[i-2];
}
// 把最终结果返回
return dp[n];
}
4.4 编辑距离问题
对于序列S和T, 它们之间的距离定义为: 对二者其一进行几次以下操作: 1, 删除一个字符; 2, 插入一个字符; 3, 改变一个字符. 每进行一次操作, 计数增加1. 将S和T变为相等序列的最小计数就是两者的编辑距离(edit distance)或者叫相似度. 请给出相应算法及其实现.
定义二维数组dp[m][n]
,含义为S序列的前m个字符编辑到T数组的前n个字符需要的最小的编辑距离。
那么,思考dp[i][j]
与dp[i-1][j-1]
, dp[i][j]
, dp[i-1][j]
的关系。发现,dp[i][j]
=1+min{dp[i-1][j]
,dp[i-1][j-1]
,dp[i][j-1]
}。(此处可以画个图,会更清晰一些)
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
for (int i = 0; i <= m; ++i) dp[i][0] = i;
for (int i = 0; i <= n; ++i) dp[0][i] = i;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (word1[i - 1] == word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = min(dp[i - 1][j - 1], min(dp[i - 1][j], dp[i][j - 1])) + 1;
}
}
}
return dp[m][n];
}