文章目录
1. 动态规划
1.1 基本思想
将待求解问题分解成若干个问题的子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的师,动规分解得到的子问题不是相互独立的,若用分治法则会产生很多的重复计算,因此在动态规划中采用一个表来记录所有已求解的子问题的答案。
1.2 设计步骤
(1)找出最有解的性质,并刻划画其结构特征
(2)递归地定义最优值(写出动态规划方程)
(3)以自底向上的方式计算最有值
(4)根据计算最优值的信息计算最优解
1.3 两个要素(特征)
(1)最优子结构:原问题的最优解包含子问题的最优解时,称该问题具有最优子结构性质
(2)重叠子问题:在用递归算法自顶向下求解问题时,每次产生的子问题不总是最新的,有些子问题被反复计算多次。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
1.4 动态规划实质
动态规划的实质是分治思想和解决冗余,动态规划算法是将问题分解为更小的、相似的子问题,并存储子问题的解而避免计算重复的子问题,以解决最优化问题的算法策略。
2. 典型算法案例
2.1 最长单调递增子序列
2.1.1 问题描述
设计一个 O(n2) 时间的算法,找出由 n 个数组成的序列的最长单调递增子序列。
输入: 第1个整数n(0<n<100),表示后面有n个数据,全部为整数。
输出:输出最长单调递增子序列的长度
样例输入:8 65 158 170** 155 239 300 207 389
样例输出:6
2.1.1 递归方程
![image-20210608185657148](https://gitee.com/ikrain/blog-image/raw/master/img/20210609163326.png)
求解b数组。
当 i = 1时,只有一个数据,长度为1。
当 i != 1时,等于 a[i] 之前所有比他小的元素对应的最大数组b的值加1。其中,比 a[i] 小的元素要在他之前。
2.1.2 代码实现
/**
@author cc
@date 2021/6/8
@Time 19:03
求解最长单调递增子序列
*/
#include "iostream"
using namespace std;
#define NUM 100
int a[NUM];
/**
* 构造b数组
* @param n 数据个数
* @return
*/
int LIS_n2(int n){
int b[NUM] = {0};
int i, k;
b[1] = 1;
int max = 0;
for (i = 2; i <= n; i++) {
int z = 0; // 记录a[i]之前比他小的元素的值
for (k = 1; k < i; k++)
if (a[k] <= a[i] && z < b[k]) z = b[k]; // 寻找 a[i] 之前所有比他小的元素对应的最大数组b的值
b[i] = z+1; // 给b数组赋值,对应递推公式的 b[i] = max{b[k]} + 1;
if (max < b[i]) max = b[i]; // 寻找b数组中的最大值,即为最长单调递增子序列的长度
}
return max;
}
int main(){
int n;
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
cout << LIS_n2(n);
return 0;
}
2.2 0-1背包问题
2.2.1 问题描述
给定一个物品集合s={1,2,3,…,n},物品 i 的重量是 wi,其价值是 vi,背包的容量为W,即最大载重量不超过W。在限定的总重量W内,我们如何选择物品,才能使得物品的总价值最大。
如果物品不能被分割,即物品 i 要么整个地选取,要么不选取;
不能将物品 i 装入背包多次,也不能只装入部分物品 i,则该问题称为0-1背包问题。
如果物品可以拆分,则问题称为背包问题,适合使用贪心算法。
2.2.1. 递归方程
- 约束方程:
![image-20210608162338911](https://gitee.com/ikrain/blog-image/raw/master/img/20210609163332.png)
- 目标函数
其中xi=0 或 1,等于0表示不选,等于1表示选。
![image-20210608173739181](https://gitee.com/ikrain/blog-image/raw/master/img/20210609163335.png)
在递推式当中,p(n,j)表示第n个(最后一个)物品是否放入(自底向上)。
p(i+1, j)表示:物品 i 不装入背包,可能由于无法装入,也可能是因为装入后的价值小于装入前的价值。因此问题转化为前 i+1
个物品放入容量为 j 的背包。
p(i+1, j - wi) + vi 表示:装入物品 i,新增价值 vi ,背包容量边为 j - wi,问题转化为对前 i+1个物品放入容量为 j-wi 的背包内。
- 案例:
![image-20210608174104953](https://gitee.com/ikrain/blog-image/raw/master/img/20210609163338.png)
在求解过程中,递推公式中“()”内的下标,表示二维数组的下标。比如在计算37时,背包容量为5,对于物品1,其重量为2,可以放下,此时计算放入与不放入那个价值大。
物品1不放入: p(1+1, 5) = 35
物品1放入:p(1+1, 5-2) + 12 = 25+12 = 37
37 > 35 因此物品1放入背包。
- 草纸自算过程
![IMG_20210608_180047](https://gitee.com/ikrain/blog-image/raw/master/img/20210609163343.jpg)
第一行前面的数据其实已经不用再计算了,因为已经到了最后一个标号为1的物品了(我们从n开始,自底向上),已经不需要该行的数据为上一层服务了。
2.2.3 复杂度
- 时间复杂度:O(nW) W为背包容量。
2.2.4 代码实现
/*
@author cc
@date 2021/4/17
@Time 10:52
To change this template use File | Settings | File Templates.
0-1背包问题
*/
#include "bits/stdc++.h"
using namespace std;
#define NUM 50 // 物品数量上限
#define CPA 1500 // 背包容量上限
int w[NUM]; // 物品重量
int v[NUM]; // 物品价值
int p[NUM][CPA]; // 用于递归的数组
// 形参c是背包的容量W,n是物品的数量
void knapsack(int c, int n){
// 计算递推边界
int jMax = min(w[n]-1,c); // 分界点,min函数判断两个数的大小,返回较小的数
// 处理边界情况,先对最小子问题进行求解,根据递推式p(n,j)填充表p中第n个物品行的值
for (int j = 0; j <= jMax; j++) p[n][j] = 0;
for (int j = w[n]; j <= c; j++) p[n][j] = v[n];
// 根据递推式p(i,j)计算一般子问题的解
for (int i = n-1; i > 1; i--) { // 计算递推式
jMax = min(w[i]-1,c);
// 物品重量大于背包容量时,不放入背包,该子问题的解等于前一个子问题的解
for (int j = 0; j <= jMax; j++) {
p[i][j] = p[i+1][j];
}
// 物品重量小于背包容量时,通过判断是否能够取得更大的价值来决定是否放入背包
for (int j = w[i]; j <= c; j++) {
p[i][j] = max(p[i+1][j],p[i+1][j-w[i]]+v[i]);
}
}
// 处理边界情况,最后根据子问题的解计算原问题的解
p[1][c] = p[2][c]; // 计算最优值
if (c>=w[1])
p[1][c] = max(p[1][c],p[2][c-w[1]]+v[1]);
}
// 形参数组x是解向量
void traceback(int c, int n, int x[]){
for (int i = 1; i < n; ++i) {
if (p[i][c]==p[i+1][c]) x[i]=0;
else{
x[i] = 1;
c-=w[i];
}
}
x[n] = (p[n][c]) ? 1 : 0;
}
int main(){
int c, n;
cout << "请输入背包的容量:";
cin >> c;
cout << "请输入物品的数量:";
cin >> n;
int x[n];
cout << "请输入物品的重量及其价值(用空格隔开):" << endl;
for (int i = 1; i <= n; ++i) {
cin >> w[i] >> v[i];
}
knapsack(c,n);
cout << "最优值为:" << p[1][c] << endl;
traceback(c,n,x);
cout << "最优解为(1表示该物体放入背包,0表示不放入):" << endl;
for (int j = 1; j <= n; ++j) {
cout << x[j] << " ";
}
return 0;
}
2.3 矩阵连乘积问题
2.3.1 问题描述
给定n个矩阵{A1,A2,…,An},其中Ai与Ai+1是可乘的,i=1,2…,n-1。如何确定计算矩阵连乘积的计算次序,使得依此次序计算矩阵连乘积需要的数乘次数最少?
矩阵A和B可乘的条件: 矩阵A的列数等于矩阵B的行数。设A是p×q的矩阵, B是q×r的矩阵, 乘积是p×r的矩阵;则矩阵A和矩阵B相乘所需计算量是:pqr。
2.3.2 建立递推公式
数组 m[i][j]
表示从第 i 个矩阵到第 j 个矩阵连乘所需的连乘次数。当 i = j 时,只有一个矩阵,因此连乘次数为 0 。其中 k 表示从第 i 个矩阵到第 j 个矩阵断开的位置(k的位置只有 j - i 种可能)。我们在计算矩阵连乘积最小值时,需要 m 数组、p 数组、s 数组辅助。
-
p 数组(从1开始)中存放每个矩阵的列值,但p[0] 记录第一个矩阵的行值;
-
m 数组存放在各不同子链长下最小的矩阵连乘积,即子问题的解;
-
s 数组存放在不同子链长下,断开的位置(在该断开的位置取得当前链长连乘积的最小值);
动态规划最大的特点就是 用空间换取时间 ,从这三个数组中可以体现出来。
举例:
A1 | A2 | A3 | A4 | A5 | A6 |
---|---|---|---|---|---|
50x10 | 10x40 | 40x30 | 30x5 | 5x20 | 20x15 |
p数组:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
值 | 50 | 10 | 40 | 30 | 5 | 20 | 15 |
比如我们计算 m[2][5]
,此时k可取2、3、4三个值,也就是有三个断开位置,分别计算在这三种断开位置下从矩阵2 到矩阵5 所需要的连乘积次数,取最小值填入 m[2][5]
。
在填充m数组的过程中,注意按照从主对角线开始斜向右下的顺序计算子问题的解,这样我们在计算到较大子问题的解时,可以用到之前所计算的子问题的解。所计算得到的m数组如下。
![image-20210609082929908](https://gitee.com/ikrain/blog-image/raw/master/img/20210609163356.png)
2.3.3 复杂度
- 时间复杂度:O(n^3)
- 空间复杂度:O(n^2)
2.3.3 代码实现
/*
@author cc
@date 2021/4/12
@Time 10:32
To change this template use File | Settings | File Templates.
矩阵连乘问题
*/
#include "iostream"
using namespace std;
#define NUM 51
int p[NUM]; // p数组(从1开始)中存放每个矩阵的列值,但p[0]记录第一个矩阵的行值
int m[NUM][NUM]; // m数组存放在各不同子链长下,最小的矩阵连乘机
int s[NUM][NUM]; // s数组存放在不同子链长下,断开的位置(在该断开的位置取得当前链长连乘积的最小值)
void MatrixChain(int n){
// m数组,另主对角线为零,也就是单个矩阵时,连乘积为0
for (int i = 1; i <= n; ++i) {
m[i][i] = 0;
}
// r表示矩阵链的长度,即:所求子问题中 矩阵的个数
// 再动态规划算法中,我们先计算问题的解,再分割子问题时,就从头开始,第一次r的循环让r=2,就是控制矩阵的个数为2
// 然后让r=3,即子问题中矩阵的个数为3 直到矩阵个数为n
for (int r = 2; r <= n; ++r) {
for (int i = 1; i <= n-r+1 ; ++i) {
int j = i+r-1;
// 计算初值,在i出断开
// r大于等于3之后,会先计算在i断开的情况,因为m[i][i]等于零,下面的式子中可不写
// 使用s[i][j]记录断开的位置
// 计算完在i出断开的后,进入k+1的循环,即断开位置开始相后移动
m[i][j] = m[i+1][j]+p[i-1]*p[i]*p[j];
s[i][j] = i;
// r大于等于3之后,计算完再i出断开的情况后,让k=i+1,即断开处开始向后移动,直到移动到最后一个矩阵前停止
// 每移动到一个新的断开位置,都要计算其连乘积,并与已经存入表内的值多对比,取较小者存入表内
for (int k = i+1; k < j; ++k) {
int t = m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
if (t<m[i][j]){
m[i][j] = t;
s[i][j] = k;
}
}
}
}
}
// 递归输出分割好的矩阵序列
void TraceBack(int i, int j){
if (i==j){
cout << "A" << i;
} else{
cout << "(";
TraceBack(i,s[i][j]);
TraceBack(s[i][j]+1,j);
cout << ")";
}
}
int main(){
int n;
cout << "请输入矩阵的个数:";
cin >> n;
cout << "请输入各矩阵的行列数,用空格隔开:";
// 构造P数组
for (int i = 0; i <= n; ++i) {
cin >> p[i];
}
MatrixChain(n);
cout << "最小连乘积为:";
cout << m[1][n] << endl;
cout << "最优解为:";
TraceBack(1,n);
return 0;
}
3. 动态规划与分治法区别
- 相同点
将待求解的问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。
- 不同点
适合于用动态规划法求解的问题,经分解得到的子问题往往 不是互相独立 的。而用分治法求解的问题经分解得到的子问题往往是互相独立的。
4. 动规与分支对问题进行分解时各自所遵循的原则
- 分治算法
将待求解问题分解为若干个规模较小、相互独立且与原问题相同的子问题(不包含公共的子问题)。
- 动态规划
将待求解问题分解为若干个规模较小、 相互关联 的与原问题类似的子问题(包含公共的子问题),采用记录表的方法来保存所有已解决问题的答案,而在需要的时候再找出已求得的答案,避免大量的重复计算。