1.什么是动态规划算法?
动态规划算法是指将复杂问题拆分为简单问题,并存储简单问题的结果,避免重复计算。
也就是说动态规划算法需要满足以下3个特点:
- 1.可以将问题分解为相似的子问题,并且子问题有重复
- 2.每一个子问题只解决一次
- 3.存储子问题解决后的结果
2.什么样的问题可以用动态规划解决?
可以用动态规划解决的问题主要有如下3个特征:
- 1. 重复的子问题
- 2. 最优子结构
- 3. 无后效性
重复子问题是指:原问题分解得到的子问题中有完全相同的子问题
最优子结构就是指:原问题的最优解可以通过子问题的最优解的解决而解决
无后效性是指:原问题的解可以通过子问题的解获得,而不用管子问题的解是如何获得的 或者说 如果给定某一阶段的状态,则 在这一阶段以后过程的发展不受这阶段以前各段状态的影响。
有点抽象,下面以例子进行说明
2.1以斐波那契数列(Fibonacci)为例子
从n=0开始的Fibonacci数列:
0 , 1, 1, 2, 3, 5, 8, 13 ,21 ...
下面我们要计算Fibonacci的fib(n)如何计算呢?
首先我们看要计算fib(n)能否拆分为子问题,在这里很明显
fib(n) = fib(n-1) + fib(n-2)
那么我们要求fib(5),如何求解呢?
我们通过递归来求解:
int fib(int n)
{
if ( n <= 1 )
{
return n;
}
return fib(n-1) + fib(n-2);
}
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ / \ / \
fib(2) fib(1) fib(1) fib(0) fib(1) fib(0)
/ \
fib(1) fib(0)
很明显,在求解过程中,我们需要求解fib(3)两次,fib(2)三次。。。。
求解了相同的子问题,每次求解子问题都需要耗费时间,那为什么不将子问题的结果存储起来,再用到相同子问题的时候直接取出来结果就可以,而不用再次计算,从而节省了时间(用空间来换取时间);而这正是动态规划的意义所在,降低程序时间复杂度,提高程序运行效率
在斐波那契数列求解fib(5)只需求解fib(4)和fib(3),最优子结构这一特征并不明显,但我们不用管fib(4)和fib(3)是怎么求解出来的,直接拿过来用就行了,满足无后效性。
所以,可以用动态规划方法来求解。
动态规划中有两种不同的方法来存储子问题的结果:
- 1.自上而下
针对问题的自上而下存储子问题结果的程序类似于递归版本,只有一个小修改,它在计算解决方案之前会查找存储表。 我们初始化一个查找数组,所有初始值都为INT_MAX。 每当我们需要子问题的解决方案时,我们首先查看查找表。 如果预先计算的值在那里,那么我们返回该值,否则,我们计算该值并将结果放在查找表中,以便以后可以重用它。
vector<int> look_up(n+1,INT_MAX);//查找表一个包含n+1个元素,因为有fib(0),全部初始化为INT_MAX,不为INT_MAX说明该子问题已经被解决过
int fib(int n)
{
if (look_up[n] == INT_MAX)//fib(n)还未被求解过
{
if (n <= 1)
lookup[n] = n;
else
lookup[n] = fib(n - 1) + fib(n - 2); //求解出来后保存到look_up
}
return look_up[n]; //否则,fib(n)已经求解过,再次用的时直接取出返回即可
}
- 2. 自下而上
自底部向上,一般以迭代的方式进行,给定问题以自下而上的方式构建表,并返回表中的最后一个值。 例如,对于相同的斐波纳契数,我们首先计算fib(0)然后计算fib(1)然后计算fib(2)然后计算fib(3),依此类推。 通过建立fib(0)和fib(1)而建立出fib(2)
//迭代的方式自下而上
int fib(int n)
{
vector<int> look_up(n+1,INT_MAX);
int i;
look_up[0] = 0;
look_up[1] = 1;
for (i = 2; i <= n; i++)
{
look_up[i] = look_up[i-1] + look_up[i-2];
}
return look_up[n];
}
进一步优化:
通过上述程序,我们发现我们求解的目的是fib(n),但是我们保留了大量的中间结果,这是没必要的,我们只需要保留要求解fib(i)所需要的前两个状态fib(i-1)与fib(i-2)
所以,我们可以用两个变量来代表fib(i)之前的两个状态,减少所用的存储空间
//迭代的方式自下而上,不保留中间结果进行优化
int fib(int n)
{
int i;
pre1 = 0;
pre2 = 1;
res = 0;//fib(n)最后返回结果
for (i = 1; i <= n; i++)
{
res = pre1 + pre2;
pre1 = pre2;
pre2 = res;
}
return res;
}
大多数动态规划问题的解题思路可以按以下步骤进行:
- 找到递归关系(如何找其实就是如何拆分问题,下面会讨论)
- 写出递归程序
然后选择一种方法优化,熟练之后可以省略写递归程序这一步
- 递归程序+存储子问题结果 自上而下
- 迭代程序+存储子问题结果 自下而上
- 迭代程序 + N个变量 自下而上
2.2以最短路径为例
我们以最短路径为例主要说明最优子结构特性
最优子结构特性是指:原问题的最优解可以通过子问题的最优解推出来
以下图有向图为例,求解节点q到节点t的最短路径,很明显是 q -> r -> t或者 q -> s ->t
若节点r在最短路径中,那么q -> t的最短路径 就等于 q->r 和 r->t 的最短路径,拆分为了两个子问题的最优解,求出子问题的最优解,那么原问题的最优解就可以推出来
即最短路径问题满足最优子结构特性
但是并不是所有的问题均满足最优子结构特性,比如最长路径问题,还是以上图为例,
我们要求q -> t的最长路径,是q -> r -> t或者 q -> s ->t,
若已知r在最长路径中,那么原问题是不能拆分为 q->r 和 r->t的最长路径的组合的,
因为q->r的最长路径是q->s->t->r, 而r->t的最长路径是r->q->s->t,两者结合并不能推出原问题的最优解
所以不符合最优子结构问题
3.怎么解决动态规划问题?
解决动态规划问题的关键在于如何拆分问题,这是解决动态规划问题的关键点
解决动态规划问题时可以按以下思路进行尝试:
- 将当前要解决的问题 i 视为当前状态,我们的目标是求出 f(i) ;比如我们求斐波那契数列的第n个数,n就是当前的状态,f(n)是我们问题的目标
- 找目标f(i)与那些状态有关,假设与状态x有关,找出f(x)与f(i)之间的状态转移关系; 比如斐波那契数列目标f(n)与n-1状态和n-2状态有关,找出f(n)与f(n-1)与f(n-2)之间的状态转移关系, f(n) = f(n-1) + f(n-2)
- 有了状态转移关系就可以很容易运用
- 动态规划方法来解决问题
以例子来进行实战解决动态规划问题:
- 上楼梯问题
问题描述:你正在爬楼梯。 你需要n步才能达到顶峰,每次你可以爬1或2步, 您可以通过多少不同的方式登顶?
设当期状态为 i,i 表示i步才能到达的地方,我们由 f(i) 种方式到达 i 处,下面我们来看看 f(i) 与哪些状态有关,由于每次只能爬一步或者两步,只要到达了 i-1 处或者 i-2 处,我们就可以轻松到达 i 处,所以 f(i) 的状态与 f(i-1) 和 f(i-2)有关,所以状态转移关系为:
f(i) = f(i-1) + f(i-2)
发现没有,这其实就是上面提到的 斐波那契数列,所以程序不再赘述
- 抢劫问题
问题描述:
假设你是一个专业的强盗,计划在街上抢劫房屋。 每个房子都有一定数量的钱存在,阻止你抢劫他们的唯一限制是邻近的房子连接了安全系统,如果两个相邻的房子在同一个晚上被打破,它将自动联系警察。给出一个数组nums包括这条街道上房子包含的金额数,确定你今晚可以抢劫的最高金额
比如:[1,2,3,1] 则在不触发安全系统的前提下你抢的金额最大为 1 + 3 = 4
现在 假设你已经来到了第 i 间房子处,设你 抢到第 i 间房子处时,总最大金额为 f(i)
为了不触发安全系统,且为了达到最大净额,f(i) 的状态与 f(i-1) 和 f(i - 2)有关,假如你已经抢了第 i-1 间房子,那么你肯定不能抢第 i 间房, f(i) = f(i-1);但若你抢的是第 i-2 间房,那么你可以抢第 i 间房,总金额 f(i) = f(i-2) + nums[i]
nums[i] 表示从第 i 间房抢的金额
所以状态转移关系应该为:
f(i) = max( f(i-1), f(i-2) + nums[i])
自上而下的递归程序为:
class Solution {
public:
int rob(vector<int>& nums)
{
vector<int> memo(nums.size(),INT_MAX);
return rob(nums,nums.size()-1,memo);
}
private:
int rob(vector<int>& nums,int n,vector<int>& memo)
{
if(n < 0)
{
return 0;
}//递归结束条件
if(memo[n] != INT_MAX)
{
return memo[n];
}
int result = max(rob(nums,n-1,memo),rob(nums,n-2,memo)+nums[n]);
memo[n] = result;
return result;
}
- 必谈的最长上升子序列(LIS)问题
给定长度为n的序列a,从a中抽取出一个子序列,这个子序列需要单调递增。问最长的上升子序列(LIS)的长度。
e.g. 1,7,2,8,3,4 中的最长上升子序列为 1 2 3 4 ,所以长度为4
现在我们开始拆分问题:假设我们当前状态为 i,表示序列a的下标,则我们一定可以找到一个以a[i]为结尾的上升子序列,(即在这个上升序列中必须以a[i]为结尾,无论它是否是最长的)
此时LIS为f(i);下面找f(i)与哪些状态有关,
很明显,f(i)与a[i]之前的序列的最长上升序列有关;设之前的最大上升序列为f(p),p为a的下标,p可以是i之前的任意下标,而且f(p)是以a[p]为结尾的最长子序列
那么如果a[p] < a[i],那么序列最长上升序列+1
所以写成状态转移方程:
f(i) = max(f(p)) + 1; 0=< p < i
所以,程序如下:
class Solution {
public:
int lengthOfLIS(vector<int>& nums)
{
if(nums.size() == 0)
{
return 0;
}
vector<int> look_up(nums.size()+1,1);
for(int i = 0;i < nums.size();i++)//表示求以a[i]结尾的序列的最长子序列长度
{
for(int p = 0;p < i;p++)//将a[i]依次拼接在a[p]后面,判断最长子序列长度是否变化
{
if(nums[p] < nums[i])
{
look_up[i] = max(look_up[i],look_up[p]+1);//选出拼接在a[p]后面的最大长度值
}
}
}
int ans = 1;
//look_up[i]中求的都是以a[i]为结尾的最长上升子序列长度,所以需要遍历找出最长的上升子序列长度
for(int j = 0;j < nums.size();j++)
{
ans = max(ans,look_up[j]);
}
return ans;
}
};
参考: https://www.geeksforgeeks.org/overlapping-subproblems-property-in-dynamic-programming-dp-1/
https://www.zhihu.com/question/23995189/answer/35429905
https://www.zhihu.com/question/23995189