C++ 算法进阶系列之再聊聊动态规划的两把刷子

1. 前言

递归动态规划是算法界的两个扛把子,想进入算法之门,则必须理解、掌握这两种算法的本质。一旦参悟透这2种算法的精髓,再加上对树、图等复杂数据结构的深入理解,可以解决大部分的算法问题。

本文通过几个典型案例,再次聊聊动态规划算法。其实动态规划算法也就 2 把刷子。

  • 找到当前子问题的所有可选择项,在所有选择项中选择最大值或最小值。
  • 此子问题的最优解,作为下一个子问题的可选择项。最终推导出最终结果。每一个子问题只需关心与其有依赖子问题的结果而无需关注其实现过程。

动态规划的基本理念:步步为营,每次选择最好,达到最终是最好的。

如下通过几个案例来理解动态规划如何步步为营。

2. 最多的 A

2.1 问题描述

现假设有一个特殊的键盘包含下面的按键:

  • Key 1: (A):在屏幕上打印一个A

  • Key 2(ctrl-A):选中整个屏幕。

  • Key 3: (ctrl-c):复制选中区域到缓冲区

  • Key 4: (ctrl-v) : 将缓冲区内容输出到上次输入的结束位置,并显示在屏幕上。

现在,你只可以按键 N 次(使用上述四种按键) ,请问屏幕上最多可以显示几个 A呢?

2.2 样例

2.2.1 样例 1

输入:N =3

输出:3

解释:我们最多可以在屏幕上显示三个,A通过如下顺序按键:
A,A,A

2.2.2 样例2

输入:N = 7

输出:9

解释:我们最多可以在屏幕上显示 9 个,A通过如下顺序按键
A,A,A,ctrl-A,ctrl-c,ctrl-V, ctrl V

2.3 问题解析

本题是求最值问题,可使用动态规划实现。如前所说,要使用动态规划算法,首先要知道对于每一个子问题而言有那些选择项。

Tips: 于本题而言,不同的按键次数可以认为是一个个子问题。

在屏幕上输出A,也就是让屏幕上的A字符的个数发生变化,可以有2种选择:

  • 直接按下A键。只需要一次按键就能输出`A`。
  • 复制屏幕上的A。先按下ctrl+A、ctrl+C,在缓冲区添加内容 ,然后可以重复按ctrl+v在屏幕上输出字母A

则在不同的按键次数下,哪一种选择最佳?

本题中动态规划算法要做的是:

  • 由小规模状态下的积累得到到大规模状态下的结果。此题要计算的是当按键次数的变化下子母A的个数。
  • 当次数状态量发生变化后,需要选择出最理想方案。

解题流程:

可以先定义一个一维 dp 数组。用来存储不同次数状态下子母A的个数。

现分析在不同次数下,哪一种选择方案可得到最理想结果。

  • 当按键次数为1时。此状态下只可能通过按下A键输出子母A
  • 当按键次数为 2时。也只能通过直接按下A键输出子母A,这时屏幕上的字母个数为 dp[2]=dp[1]+1
  • 当按键次数为3时。通过直接按`A`键,也可以通过复制输出A 。

    直接按下A键的个数为dp[3]=dp[2]+1

复制输出`A`。因复制的前置条件是要先按下`ctrl+A、ctrl+C`,意味着需要消耗掉2次按键,`ctrl+V`复制的内容应该是`dp[0]`位置字母`A`的个数。

​ 显然,直接按键输出的字母A更多。在两个方案中选择直接按下子母键为最佳方案。

  • 当按键次数为4时。

    直接按下A键输入A,此时屏幕上的A字符为4个。

使用复制方案在屏幕上输出时。复制方式有 2 种:

dp数组位置1ctrl+A、在2ctrl+C。这样复制的是dp[0]位置的子母个数。

dp数组位置2ctrl+A、在位置3ctrl+C。这样复制的是dp[1]位置的子母个数。

编码实现:

c++ 复制代码
#include <iostream>
using namespace std;

//一维动态数组
int dp[100]= {0};
//按键次数
int n;
int main(int argc, char** argv) {
   	cin>>n;
	for(int i=1; i<=n; i++) {
		//直接按下A键
		dp[i]=dp[i-1]+1;
		//如果复制
		for( int j=2; j<i; j++ ) {
			dp[i]=max( dp[i],dp[j-2]*(i-j+1) );
		}
	}
    cout<<dp[n];
	return 0;
}

3. 最长递增子序列

3.1 问题描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

  • 输入:[10,9,2,5,3,7,101,18]

  • 输出:4

解释:最长的上升子序列是 [2,3,7,101],它的长度是 4

说明:可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。子序列和子串的区别,子串是连续的,子序列不一定是连续的。

### 3.2 问题分析

如何使用动态规划思想解决此问题。

  • 创建一维动态dp数组。记录当数组中的数据规模变化时,其子序列的长度。初始值为 1,数列是自己的子序列。
  • 从左向右扫描原始数组,扫描到数据 10时,显然,其子序列的个数为 1
  • 扫描到数据 9时,将其和前面的 10 进行比较,因比其小,故9不能为递增子序列做出贡献,保留原来子序列的个数。
  • 扫描到2时,其对应dp数组中的值为 1
  • 扫描到5时,其比10、9小,但比2大,可以成为以2为当前状态值的递增子序列。
  • 扫描到3时,因3只比2大,此时最长子序列应该是以2结束时的最长子序列加1
  • 扫描到7时,因 72,5,3都大,则需要在以2、5、3结束时最长子序列中求最大值。动态规划的特点就是,状态的改变时,往往需要在多个选择中选择最佳的。
  • 同理,当扫描到101,因为它比前面的所有数字都大,则需要在已经填充的dp数组中找出最大值且再加 1
  • 按相同的原理,最后 dp数组中的值应该如下所示。

3.3 编码实现

c++ 复制代码
#include <iostream>
#include <cstring>
using namespace std;

//原始数组
int nums[]= {10,9,2,5,3,7,101,18};
//一维动态数组
int dp[100]= {1};

int main(int argc, char** argv) {
	int size=sizeof(nums)/sizeof(int);
	//最长子序列
	int maxVal=1;
	//遍历原始数组
	for( int i=0; i<size; i++ ) {
		dp[i]=1;
		//以 i 为当前位置,向原始数组之间扫描
		for( int j=i-1; j>=0; j-- ) {
			if( nums[i]>nums[j] ) {
				dp[i]=max(dp[i] ,dp[j]+1 ) ;
			}
		}
		if(maxVal<dp[i]) maxVal=dp[i];
	}
	for(int  i=0; i<size; i++)
		cout<<dp[i]<<"\t";
	cout<<"\n最长子序列长度:"<< maxVal<<endl;
	return 0;
}

4. 最小路径和

4.1 问题描述

现有一个二维数组nums,其中的元素都是非负整数 ,现在你站在左上角,只能向右或者向下移动,需要到达右下角。现在请你计算,经过的路径和最小是多少?

二维数组如下图所示。

本题是典型的动态规划类题型。

基本流程如下:

  • 基于动态规划的基本思想,先创建一个二维dp数组。存储出发位置到表格中每一个位置的最短路径之和。动态规划的基本套路就是步步为营,如果能保证从出发点到每一个位置的路径和都是最小的,自然能求解出到目标地的最短路径和。
  • dp表先填充一些显然易见的值,也称为base case。如出发点的最短路径是本身,如下图所示:
  • dp表中的第一行的值,只受左边值的影响,不存在多个选择,也容易找出来。其值为dp[0][i]=dp[0][i-1]+nums[0][i]
  • dp表中的第一列的值只受上边值的影响,也不存在多个选择,其值为dp[i][0]=dp[i-1][0]+nums[i][0]
  • 其它位置的值,需要在上边和左边的值里选择最小值后再与原数组中同位置的值相加。如下图所示A位置可以有2 个选择,选择其中较小的值。
  • 以此类推,可得到余下所有位置的值。如下图所示,红色数字表示最终的选择。
  • 最后绘制出从出发点到目标地的最短路径之和。

4.2 编码实现

c++ 复制代码
#include <iostream>
#include <cstring>
using namespace std;

//原始数组
int nums[3][3]= { {2,4,3},
	{7,6,2},
	{1,8,5},
};
//二维动态数组
int dp[3][3]= { {0,0,0},
	{0,0,0},
	{0,0,0},
};

int main(int argcwfh, char** argv) {

	//起始位置的路径路径是本身
	dp[0][0]=nums[0][0];
	//第一行值只受左边值影响
	for(int i=1; i<3; i++ ) {
		dp[0][i]=dp[0][i-1]+nums[0][i];
	}
	//第一列值只受上边影响
	for(int i=1; i<3; i++ ) {
		dp[i][0]=dp[i-1][0]+nums[i-1][0];
	}

	//由上向下
	for(int i=1; i<3; i++) {
		for(int j=1; j<3; j++ ) {
			dp[i][j]=nums[i-1][j-1]+min(dp[i][j-1],dp[i-1][j]);
		}
	}

	for(int i=0; i<3; i++) {
		for(int j=0; j<3; j++ ) {
			cout<<dp[i][j]<<"\t";
		}
		cout<<endl;
	}

	return 0;
}

5. 总结

递归、动态规划是算法世界的两大剑客,两者互通款曲,解决同一个问题时,一个站在问题域的正方向,一个站在问题域的反方向。灵活运用且掌握这两大算法,是通向算法界的必修之路。

相关推荐
羽墨灵丘17 分钟前
0-1背包问题(1):贪心算法
算法·贪心算法
scoone1 小时前
C++中的原子操作:原子性、内存顺序、性能优化与原子变量赋值
开发语言·c++
轩情吖1 小时前
模拟实现Bash
linux·c语言·开发语言·c++·后端·bash·环境变量
vampire-wpre2 小时前
我要成为算法高手-递归篇
算法·深度优先
微尘82 小时前
C++条件编译指令:#if、#elif、#ifdef、#ifndef、#elifdef、#elifndef、#else、#endif
开发语言·c++
醒了就刷牙3 小时前
Leetcode 面试150题 88.合并两个有序数组 简单
算法·leetcode·面试
丶Darling.3 小时前
Day47 | 动态规划 :线性DP 最长公共子序列&&最长公共子数组
算法·动态规划
丶Darling.3 小时前
Day48 | 动态规划 :线性DP 编辑距离
算法·动态规划
In 20294 小时前
矩阵【Lecode_HOT100】
java·算法·矩阵
qystca4 小时前
洛谷 P1722 矩阵 II C语言 记忆化搜索
算法