算法设计与分析——动态规划

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

image-20210608183040382

2.1.1 递归方程

image-20210608185657148

求解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
  • 目标函数

image-20210608162316727

其中xi=0 或 1,等于0表示不选,等于1表示选。

image-20210608173739181

在递推式当中,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

在求解过程中,递推公式中“()”内的下标,表示二维数组的下标。比如在计算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

第一行前面的数据其实已经不用再计算了,因为已经到了最后一个标号为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 建立递推公式

image-20210609074633514

数组 m[i][j] 表示从第 i 个矩阵到第 j 个矩阵连乘所需的连乘次数。当 i = j 时,只有一个矩阵,因此连乘次数为 0 。其中 k 表示从第 i 个矩阵到第 j 个矩阵断开的位置(k的位置只有 j - i 种可能)。我们在计算矩阵连乘积最小值时,需要 m 数组、p 数组、s 数组辅助。

  • p 数组(从1开始)中存放每个矩阵的列值,但p[0] 记录第一个矩阵的行值;

  • m 数组存放在各不同子链长下最小的矩阵连乘积,即子问题的解;

  • s 数组存放在不同子链长下,断开的位置(在该断开的位置取得当前链长连乘积的最小值);

动态规划最大的特点就是 用空间换取时间 ,从这三个数组中可以体现出来。

举例:

A1A2A3A4A5A6
50x1010x4040x3030x55x2020x15

p数组:

下标0123456
5010403052015

比如我们计算 m[2][5] ,此时k可取2、3、4三个值,也就是有三个断开位置,分别计算在这三种断开位置下从矩阵2 到矩阵5 所需要的连乘积次数,取最小值填入 m[2][5]

image-20210609081835696

在填充m数组的过程中,注意按照从主对角线开始斜向右下的顺序计算子问题的解,这样我们在计算到较大子问题的解时,可以用到之前所计算的子问题的解。所计算得到的m数组如下。

image-20210609082929908

image-20210609081843218

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. 动规与分支对问题进行分解时各自所遵循的原则

  • 分治算法

将待求解问题分解为若干个规模较小、相互独立且与原问题相同的子问题(不包含公共的子问题)。

  • 动态规划

将待求解问题分解为若干个规模较小、 相互关联 的与原问题类似的子问题(包含公共的子问题),采用记录表的方法来保存所有已解决问题的答案,而在需要的时候再找出已求得的答案,避免大量的重复计算。

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

krain.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值