文章目录
动态规划
第一个基本特点:所求解的问题满足最优子结构,问题可以分解为规模更小的子问题,问题的最优解依赖于子问题的最优解。
第二个基本特点:相同的子问题只需要求解一次,如果子问题的解会被多次引用,可以将子问题的解保存起来。
*动态规划算法的核心是 一个小故事。
A * “1+1+1+1+1+1+1+1 =?” *
A : “上面等式的值是多少”
B : 计算 “8!”
A 在上面等式的左边写上 “1+” *
A : “此时等式的值为多少”
B : quickly “9!”
A : “你怎么这么快就知道答案了”
A : “只要在8的基础上加1就行了”
A : “所以你不用重新计算因为你记住了第一个等式的值为8!动态规划算法也可以说是 ‘记住求过的解来节省时间’”
由上面 小故事可以知道动态规划算法的核心就是记住已经解决过的子问题的解。
动态规划算法的两种形式
①自顶向下的备忘录法
②自底向上。
1.求斐波拉契数列Fibonacci 。
先看一下这个问题:
Fibonacci (n) = 0; n = 0
Fibonacci (n) = 1; n = 1
Fibonacci (n) = Fibonacci(n-1) + Fibonacci(n-2)
先使用递归版本来实现这个算法:
int fib(int n)
{
if(n<=0)
return 0;
if(n==1)
return 1;
return fib( n-1)+fib(n-2);
}
//输入6
//输出:8
先来分析一下递归算法的执行流程,假如输入6,那么执行的递归树如下:
上面的递归树中的每一个子节点都会执行一次,很多重复的节点被执行,fib(2)被重复执行了5次。由于调用每一个函数的时候都要保留上下文,所以空间上开销也不小。这么多的子节点被重复执行,如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间。
下面就看看动态规划的两种方法怎样来解决斐波拉契数列Fibonacci 数列问题。
①自顶向下的备忘录法
i#include <iostream>
#include <algorithm>
using namespace std;
int fibmem(int n, int Memo[])
{
if (Memo[n] != -1)
return Memo[n];//如果已经求出了fib(n)的值直接返回,否则将求出的值保存在Memo备忘录中。
if (n <= 0)
Memo[n] = 0;
else if(n == 1)
Memo[n] = 1;
else Memo[n] = fibmem(n - 1, Memo) + fibmem(n - 2, Memo);
return Memo[n];
}
int main() {
int n;
cin >> n;
int* Memo = new int[n + 1];
for (int i = 0; i <= n; i++)
Memo[i] = -1;
cout << fibmem(n, Memo);
}
备忘录法也是比较好理解的,创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fib(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在Memo数组中,下次在调用fib(n)的时候就不会重新递归了。比如上面的递归树中在计算fib(6)的时候先计算fib(5),调用fib(5)算出了fib(4)后,fib(6)再调用fib(4)就不会在递归fib(4)的子树了,因为fib(4)的值已经保存在Memo[4]中。
②自底向上的动态规划
备忘录法还是利用了递归,上面算法不管怎样,计算fib(6)的时候最后还是要计算出fib(1),fib(2),fib(3)……,那么何不先计算出fib(1),fib(2),fib(3)……,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。
#include <iostream>
#include <algorithm>
using namespace std;
int fib(int n,int Memo[])
{
if (n <= 0)
return n;
Memo[0] = 0;
Memo[1] = 1;
for (int i = 2; i <= n; i++)
{
Memo[i] = Memo[i - 1] + Memo[i - 2];
}
return Memo[n];
}
int main() {
int n;
cin >> n;
int* Memo = new int[n + 1];
for (int i = 0; i <= n; i++)
Memo[i] = -1;
cout << fib(n, Memo);
}
自底向上方法也是利用数组保存了先计算的值,为后面的调用服务。观察参与循环的只有 i,i-1 , i-2三项,因此该方法的空间可以进一步的压缩如下。
#include <iostream>
#include <algorithm>
using namespace std;
int fib(int n)
{
if (n <= 1)
return n;
int Memo_i_2 = 0;
int Memo_i_1 = 1;
int Memo_i = 1;
for (int i = 2; i <= n; i++)
{
Memo_i = Memo_i_2 + Memo_i_1;
Memo_i_2 = Memo_i_1;
Memo_i_1 = Memo_i;
}
return Memo_i;
}
int main() {
int n;
cin >> n;
cout << fib(n);
}
一般来说由于备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销,使用自底向上的动态规划方法要比备忘录方法好。
另外,时间复杂度为二叉树的节点个数:(2^h)-1=O(2 ^N) ,空间复杂度为树的高度:h即O(N)。
分析:递归实现的代码简洁易懂,但是需要注意的是,递归由于是函数调用自身,而函数调用是有时间和空间的消耗的,每一次函数调用,都需要在内存栈中分配空间以保存参数、返回地址及临时变量,而往栈里压入数据和弹出数据都需要时间,因而递归实现的效率不如循环。
2.钢条切割
公司购买长钢条,将其切割为短钢条出售。为简化分析,假设切割过程本身没有成本,并且切割下来的短钢条长度都为一英寸的整数倍。下表给出了不同长度的钢条的价格。
钢条切割问题:给定一根长度为n英寸的长钢条,求最优切割方案,使得销售收益最大。注意,最优方案也有可能是完全不用切割。
长度为n英寸的钢条有2^(n-1)种切割方案,因为在距离钢条左端i (i = 1, 2, … , n-1)英寸处,我们总是可以选择切割或不切割。但是在实际求解过程中,可以不用遍历所有的切割方案,而采用某种方法可以将该问题分解为规模更小的子问题,以下是求解该问题的方法:
我们将钢条从左端切下长度为 i 的一段,其中i =1, 2, … , n,有n种切法,我们对这一段不再进行切割,该段的销售收益为Pi;
而右端剩下的长度为n-i,对这一段再进行切割,这是一个规模更小的子问题,其销售收益为Rn-i。
上图比较直观地展示了求解方法。显然,我们可以得到最优收益
现在使用一下前面讲到三种方法来来实现一下。
①递归版本
#include <iostream>
#include <algorithm>
using namespace std;
int cut(int p[], int n) {
if (n <= 0) {
return 0;
}
int q = 0;
for (int i = 0; i < n; i++) {
q = max(q, p[i] + cut(p, n - 1 - i));
}
return q;
}
int main() {
int p[] = {
1,5,8,9,10,17,17};
int n = sizeof(p) / sizeof(int);
cout << cut(p, n);
}
递归很好理解,如果不懂可以看上面的讲解,递归的思路其实和回溯法是一样的,遍历所有解空间但这里和上面斐波拉契数列的不同之处在于,在每一层上都进行了一次最优解的选择,q=max(q, p[i-1]+cut(p, n-i));这个段语句就是最优解选择,这里上一层的最优解与下一层的最优解相关。
②带备忘的版本
#include<iostream>
#include <algorithm>
using namespace std;
/*首先检查所需值是否已知,如果是,则直接返回保存的值。
否则,用通常方法计算所需值q,然后将此值存入r[n]。值得注意的
是,笔者在此处没有对钢条的长度n进行限制,所以n可能大于10,
于是增加了自己的判断,若n大于10,则计算n-10的子序列*/
int MemoCut(int p[], int n, int r[])
{
if (r[n] >= 0)
return r[n];
int q;
if (n == 0)
q = 0;
else
{
q = -1;
for (int i = 0; i < n; i++)
{
if (i < 10)
q = max(q, p[i] + MemoCut(p, n - 1 - i, r));
else
q = max(q, r[10] + MemoCut(p, n - 1 - 10, r));
}
}
r[n] = q;
return q;
}
int main()
{
int n;
cout << "请输入钢条的长度:";
cin >> n;
//对应于长度为1,2...10的钢条价格表
int p[10] = {
1,5,8,9,10,17,17,20,24,30 };
//将辅助数组的r[0..n]元素均初始化为-1
int* r = new int[n + 1];
for (int i = 0; i <= n; i++)
{
r[i] = -1;
}
int sum = MemoCut(p, n, r);
cout << "最大收益为:" << sum << endl;
return 0;
}
有了上面求斐波拉契数列的基础,理解备忘录方法也就不难了。备忘录方法无非是在递归的时候记录下已经调用过的子函数的值。
③自底向上的动态规划
#include<iostream>
#include <algorithm>
using namespace std;
/*首先创建一个新数组r来保存子问题的解,然后将r[0]初始化为0,因为长度为0的钢条没有收益
接着对j=1,2,...,n按升序求解每个规模为j的子问题。将规模为j的子问题的解存入r[j]。最后返回r[n],即最优解*/
int BottomUpCut(int p[], int n)
{
int* r = new int[n + 1];
r[0] = 0;
for (int j = 1; j <= n; j++)//这里外面的循环是求r[1],r[2]……,钢条长度为1,2,3.....时
{
int q = -1;
for (int i = 0; i < j; i++)//里面的循环是求出r[1],r[2]……的最优解
{
if (i < 10)
q = max(q, p[i] + r[j - i - 1]);
else
q = max(q, r[10] + r[j - 10 - 1]);
}
r[j] = q; //钢条长度为j时划分的最优解保存在r[j]中
}
return r[n];
}
int main()
{
int n;
cout << "请输入钢条的长度:";
cin >> n;
//对应于长度为1,2...10的钢条价格表
int p[10] = {
1,5,8,9,10,17,17,20,24,30 };
int sum = BottomUpCut(p, n);
cout << "最大收益为:" << sum << endl;
return 0;
}
动态规划原理
虽然已经用动态规划方法解决了上面两个问题,但什么时候要用到动态规划?总结一下上面的斐波拉契数列和钢条切割问题,发现两个问题都涉及到了重叠子问题,和最优子结构。
①最优子结构
用动态规划求解最优化问题的第一步就是刻画最优解的结构,如果一个问题的解结构包含其子问题的最优解,就称此问题具有最优子结构性质。因此,某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个很好的线索。使用动态规划算法时,用子问题的最优解来构造原问题的最优解。因此必须考查最优解中用到的所有子问题。
②重叠子问题
在斐波拉契数列和钢条切割结构图中,可以看到大量的重叠子问题,比如说在求fib(6)的时候,fib(2)被调用了5次,在求cut(4)的时候cut(0)被调用了4次。如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。
3.小朋友过桥问题
题目:在一个夜黑风高的晚上,有n(n <= 50)个小朋友在桥的这边,现在他们需要过桥,但是由于桥很窄,每次只允许不大于两人通过,他们只有一个手电筒,所以每次过桥的两个人需要把手电筒带回来,i号小朋友过桥的时间为T[i],两个人过桥的总时间为二者中时间长者。问所有小朋友过桥的总时间最短是多少。
输入:
两行数据:
第一行为小朋友个数n
第二行有n个数,用空格隔开,分别是每个小朋友过桥的时间。
输出:
一行数据:所有小朋友过桥花费的最少时间。
样例:
输入
4
1 2 5 10
输出
17
解题思路:
我们先将所有人按花费时间递增进行排序,假设前i个人过河花费的最少时间为opt[i],那么考虑前i-1个人过河的情况,即河这边还有1个人,河那边有i-1个人,并且这时候手电筒肯定在对岸,所以opt[i] = opt[i-1] + a[1] + a[i] (让花费时间最少的人把手电筒送过来,然后和第i个人一起过河)
如果河这边还有两个人,一个是第i号,另外一个无所谓,河那边有i-2个人,并且手电筒肯定在对岸,所以opt[i] = opt[i-2] + a[1] + a[i] + 2*a[2] (让花费时间最少的人把电筒送过来,然后第i个人和另外一个人一起过河,由于花费时间最少的人在这边,所以下一次送手电筒过来的一定是花费次少的,送过来后花费最少的和花费次少的一起过河,解决问题)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2*a[2] }
来看一组数据 四个人过桥花费的时间分别为 1 2 5 10
具体步骤是这样的:
第一步:1和2过去,花费时间2,然后1回来(花费时间1);
第二歩:3和4过去,花费时间10,然后2回来(花费时间2);
第三部:1和2过去,花费时间2,总耗时17。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int lowTime(int F[], int n)
{
vector<int> ans;
ans.push_back(0);
ans.push_back(F[1]);
ans.push_back(F[2]);
for (int i = 3; i <= n; i++)
ans.push_back( min(ans[i - 1] + F[1] + F[i], ans[i - 2] + F[1] + F[i] + 2 * F[2]));
return ans[n - 1]; //数组下标从0开始,所以得到n个人的时间,数组下标为n-1
}
int main()
{
int n;
cin >> n;
int* F = new int[n+1];
F[0] = -1;// F[0]为默认值,F[1]为第一个小朋友过桥时间,以此类推
for (int i = 0; i < n; i++)
{
cin >> F[i];
}
sort(F, F + n + 1);
int res = lowTime(F, n + 1);
cout << "the lowTime: " << res << endl;
return 0;
}
参考文章中没有考虑到边界,比如只有一个小朋友,输入1 1,应该输出1,上述代码考虑到边界。
4.01背包问题
动态规划就是一个填表的过程。该表记录了已解决的子问题的答案。求解下一个子问题时会用到上一个子问题的答案。
给定 n 种物品和一个容量为 C 的背包,物品 i 的重量是 w[i],其价值为 v[j] 。
问:应该如何选择装入背包的物品,使得装入背包中的物品的总价值最大?
算法的主要思想,利用动态规划来解决。每次遍历到的第i个物品,根据w[i]和val[i]来确定是否需要将该物品放入背包中。即对于给定的n个物品,设val[i]、 w[i]分别为第i个物品的价值和重量,C为背包的容量。再令v[i][j]表示在面对第 i 件物品,且背包容量为 j 时所能获得的最大价值
则我们有下面的结果:
(1) v[i][0]=v[0][j]=0;//表示填入表第一行和第一列是0
(2)当w[i]>j时: v[i][j]=v[i-1][j] // 当准备加入新增的商品的容量大于当前背包的容量时,就直接使用上一个单元格的装入策略
(3)当j>=w[i]时: v[i][j]=max{v[i-1][j], val[i]+v[i-1][j-w[i]]})
//当准备加入的新增的商品的容量小于等于当前背包的容量,
//装入的方式:
v[i-1][j] 就是上一个单元格的装入的最大值
val[i]:表示当前商品的价值
v[i-1][j-w[i]]:剩余空间[j-w[i]]装入i-1商品的最大值
当j>=w[i]时,取二者最大值
算法的时间复杂度分析: 优化前:O(nc)
上述算法有两个明显的缺点:其一是算法要求所给物品的重量w[i]是整数.
例:0-1背包问题。在使用动态规划算法求解0-1背包问题时,使用二维数组m[i][j]存储背包剩余容量为j,可选物品为i、i+1、……、n时0-1背包问题的最优值。绘制
价值数组v = {8, 10, 6, 3, 7, 2},
重量数组w = {4, 6, 2, 2, 5, 1},
背包容量C = 12时对应的m[i][j]数组。
如m[2][6],在面对第二件物品,背包容量为6时我们可以选择不拿,那么获得价值仅为第一件物品的价值8,如果拿,就要把第一件物品拿出来,放第二件物品,价值10,那我们当然是选择拿。m[2][6]=m[1][0]+10=0+10=10;依次类推,得到m[6][12]就是考虑所有物品,背包容量为C时的最大价值。
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N=15;
int main()
{
int v[N]={
0,8,10,6