昨天晚上回顾了以前在onenote上记的动态规划笔记,发现很多程序都有相似之处,且最近两天写的动态规划程序都没有一遍AC。所以将这两天写的动态规划程序总结至此,以便背诵、默写用(这种题被罚时实在太亏)。
背诵的时候要特别注意dp数组的功能和其递推公式。
阅读指南
六道题分别是钱币兑换、0-1背包、完全背包、最长公共子序列、最长上升子序列、划分数。
前两题较简单,从第三题开始有公式推导,二和三题末尾有技巧总结。
一、hdu1284 钱币兑换问题
先来道最简单的背诵。
【题目描述】
一个国家只有1,2,3分钱,输入非负整数n(不超过10000),输出兑换金额n一共有多少种换法,多组输入输出。
【示例程序】
(没有一遍AC的原因写在了注释里)
#include<stdio.h>
int dp[2][10005]; //dp[i][j]代表用0~i硬币兑换金额j共有多少种换法,
//递推式是dp[i][j]=dp[i][j-a[i]]+dp[i-1][j],
//意思是不用第i种面值凑齐j加上用第i种面值的情况下凑齐j
int main(){
int n;
int a[3]={1,2,3};
//初始化dp数组
for(int j=0;j<10000;j++){
dp[0][j]=1; //只用第0种面值(1分钱)进行兑换,则无论换多少都只有一种方式
}
for(int i=0;i<3;i++){
dp[i][0]=1; //兑换金额为0,则只有一种兑换方式:所有面值都是0张
}
for(int i=1;i<3;i++){ //从第0~1种面值开始循环至用第0~2种面值
for(int j=1;j<=10000;j++){ //从兑换1分钱开始循环至兑换10000分钱
int x,y;
y=dp[i-1][j]; //y存储不用第i种面值的兑换种数
//x存储使用至少1张第i种面值的兑换种数
if(j-a[i]<0)x=0; //如果要兑换的金额数小于0,则兑换方式是0种
else x=dp[i][j-a[i]];
dp[i][j]=x+y; //用第0~i种面值兑换j分钱的种数
}
}
while(scanf("%d",&n)==1){
printf("%d\n",dp[2][n]); //不慎写成dp[3][n],导致无论n是多少,输出都为0.
}
return 0;
}
二、0-1背包问题
【题目描述】
第一行输入n代表共有n(不超过100)种物品,第二行依次输入这些物品的重量(不超过100),第三行依次输入这些物品的价值(不超过100),第四行输入背包能承受的总重(10000),输出背包能装的物品的最大总价值。
比如输入:
4
3 1 2 3
4 2 3 2
5
输出
7
【示例代码】
#include<stdio.h>
#define MAX_N 100
#define MAX_W 10000
int dp[MAX_N+1][MAX_W+1]; //dp[i][j]代表从0~i-1号这前i个物品中选择的最大总价值
int main(){
int n;
int w[MAX_N]; //物品重量
int v[MAX_N]; //物品价值
int total_w; //总重量
while(scanf("%d",&n)==1){
for(int i=0;i<n;i++){
scanf("%d",w+i);
}
for(int i=0;i<n;i++){
scanf("%d",v+i);
}
scanf("%d",&total_w);
//step1:初始化dp数组
for(int j=0;j<=total_w;j++){ //从0~-1号物品中选任何重量上限的物品,总价值都是0
dp[0][j]=0;
}
//step2:完善dp数组
//【错误一】不慎将for循环写成这样,造成了Thread 1: EXC_BAD_ACCESS (code=1, address=0x141257284)的错误,检查发现数组w中有一位数据发生了溢出(数值是一个非常小的负数)
// for(int i=1;i<MAX_N;i++){
// for(int j=0;j<MAX_W;j++){
//【错误二】将for循环写成如下这样,会导致最终需要输出的dp[n][total_w]未被赋值
// for(int i=1;i<n;i++){
// for(int j=1;j<total_w;j++){
for(int i=1;i<=n;i++){
for(int j=0;j<=total_w;j++){
int x,y;
x=dp[i-1][j]; //x存储从0~i-2号物品中选择的总价值(即不选第i-1号物品)
//y存储选择一个第i-1号物品的前提下的最大总价值
if(j>=w[i-1]){
y=dp[i-1][j-w[i-1]]+v[i-1];
}
else{ //重量上限不足以放下第i-1号物品
y=0;
}
// dp[i][j]=x+y; //【错误三,最致命】不慎写成这句话
dp[i][j]=(x>y)?x:y;
}
}
//step3:利用dp数组回答问题
printf("%d\n",dp[n][total_w]);
}
return 0;
}
【总结】
可以看出来,这种类型的动态规划的核心是初始化并完善dp数组,大致顺序就是:
0、察觉到这是动态规划题,确定大致算法流程;
1、确认dp[i][j]含义和递推式;
2、初始化dp数组;
3、完善dp数组。
然后就是利用dp数组中的元素回答问题。
这道题犯的错集中在dp数组的完善部分,说明我需要注意数组下标变化、注意递推式的正确使用,以及最终的的是:保持对dp数组功能的认知
不能一遍AC的根源
在被这两道题疯狂罚时之后,我发现我的错误都不是算法问题,而是集中在数组下标没把握好上,属于细节问题。于是博主决定不轻视任何一道题,任何题都要在纸上写出算法思路、数据结构,规定好数据范围、数组下标这类细节,然后再进行编码。抱着这样的想法,我做了一道0-1背包升级版——完全背包问题,这一次,终于一遍就AC了:
三、完全背包问题
【题目描述】
依旧是输入物品种数n,每种物品的重量,每个物品的价值,背包的承重上限,输出背包能装的物品的最大总价值。和0-1背包问题不同的是,每种物品能选无限多件。
【示例代码】
#include<stdio.h>
#define MAX_N 100
#define MAX_W 10000
int main(){
int w[MAX_N];
int v[MAX_N];
int max_w;
int n;
int dp[MAX_N+1][MAX_W+1]; //注意行数和列数,因为要多用一行所以加一
while(scanf("%d",&n)==1){
for(int i=0;i<n;i++){
scanf("%d",w+i);
}
for(int i=0;i<n;i++){
scanf("%d",v+i);
}
scanf("%d",&max_w);
//初始化dp数组
for(int j=0;j<=max_w;j++){
dp[0][j]=0;
}
//递推式完善dp数组
for(int i=1;i<=n;i++){
for(int j=0;j<=max_w;j++){
int x,y;
x=dp[i-1][j];
if(j<w[i-1]){
y=0;
}
else{
y=dp[i][j-w[i-1]]+v[i-1];
}
dp[i][j]=(x>y)?x:y;
}
}
//根据dp回答问题
printf("%d\n",dp[n][max_w]);
}
return 0;
}
展示以下我草稿纸上定义的dp数组的功能和递推关系的推导:
dp[i][j]代表从前i类(0~i-1号)物品挑选出总重不超过j的最大价值。
dp数组的初始化:显然dp[0][…]应当都为0。
递推关系推导:
d
p
[
i
]
[
j
]
=
m
a
x
{
j
>
k
∗
w
[
i
−
1
]
∣
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
−
1
]
[
j
−
w
[
i
−
1
]
]
+
v
[
i
−
1
]
,
d
p
[
i
−
1
]
[
j
−
2
∗
w
[
i
−
1
]
]
+
2
∗
v
[
i
−
1
]
,
.
.
.
,
d
p
[
i
−
1
]
[
j
−
k
∗
w
[
i
−
1
]
]
+
k
∗
v
[
i
−
1
]
}
\begin{aligned} dp[i][j]=&max\{j>k*w[i-1]|dp[i-1][j],dp[i-1][j-w[i-1]]+v[i-1],\\ &dp[i-1][j-2*w[i-1]]+2*v[i-1],...,dp[i-1][j-k*w[i-1]]+k*v[i-1]\} \end{aligned}
dp[i][j]=max{j>k∗w[i−1]∣dp[i−1][j],dp[i−1][j−w[i−1]]+v[i−1],dp[i−1][j−2∗w[i−1]]+2∗v[i−1],...,dp[i−1][j−k∗w[i−1]]+k∗v[i−1]}
这个表达式可以简化,大括号中除了第一项,其余项的最大值就是
d
p
[
i
]
[
j
−
w
[
i
−
1
]
]
+
v
[
i
−
1
]
dp[i][j-w[i-1]]+v[i-1]
dp[i][j−w[i−1]]+v[i−1],
所以,递推关系可以简化成如下:
d
p
[
i
]
[
j
]
=
m
a
x
{
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
w
[
i
−
1
]
]
+
v
[
i
−
1
]
}
,
(
如
果
j
<
w
[
i
−
1
]
,
则
去
掉
大
括
号
中
第
二
项
)
dp[i][j]=max\{dp[i-1][j],dp[i][j-w[i-1]]+v[i-1]\},(如果j<w[i-1],则去掉大括号中第二项)
dp[i][j]=max{dp[i−1][j],dp[i][j−w[i−1]]+v[i−1]},(如果j<w[i−1],则去掉大括号中第二项)
总之遇到动态规划的题,遵循以下步骤可以大大降低错误率
1、在纸上书写大致流程、数据(存储)结构;
2、规定dp数组的含义;
3、初始化dp数组;
4、确定递推式完善dp数组;
5、根据确定的dp数组回答问题。
⚠️注意不要轻视任何题目,以及有条件的话背诵一些经典的动态规划代码,比如本文写的几个。
以上所提放在其他类型的算法题上,也是适用的。
四、最长公共子序列问题
和背包问题思路不同的动态规划题。
【问题描述】分别输入字符串s和t的长度,再输入s和t两个字符串,输出s和t的最长公共子序列
比如输入:
4 4
abcd
becd
由于两个字符串的公共部分是bcd,有三个字符,则输出:
3
由于在上一题已经尝到了先在纸上分析的甜头,所以这一题先进行分析:
1、规定dp数组:dp[i][j]代表s[1]~s[i]和t[1]~t[j]的公共子序列,注意我不用s[0]和t[0],所以定义存储串s和串t的数组的长度应当额外加一;
2、初始化dp数组:dp[0][…]和dp[…][0]肯定都为0;
3、确定递推关系:
如果
s
[
i
+
1
]
=
=
t
[
i
+
1
]
s[i+1]==t[i+1]
s[i+1]==t[i+1],则
d
p
[
i
+
1
]
[
j
+
1
]
=
m
a
x
{
d
p
[
i
]
[
j
]
+
1
,
d
p
[
i
+
1
]
[
j
]
,
d
p
[
i
]
[
j
+
1
]
}
dp[i+1][j+1]=max\{dp[i][j]+1,dp[i+1][j],dp[i][j+1]\}
dp[i+1][j+1]=max{dp[i][j]+1,dp[i+1][j],dp[i][j+1]}
反之
d
p
[
i
+
1
]
[
j
+
1
]
=
m
a
x
{
d
p
[
i
]
[
j
+
1
]
,
d
p
[
i
+
1
]
[
j
]
}
dp[i+1][j+1]=max\{dp[i][j+1],dp[i+1][j]\}
dp[i+1][j+1]=max{dp[i][j+1],dp[i+1][j]}
4、程序Output:dp[n][m],n和m分别为用户输入的s和t的长度。
【示例代码】
我又一次因为纸上打草稿而避免了罚时
#include<stdio.h>
#define MAX_N 1000
#define MAX_M 1000
int main(){
int n,m;
char s[MAX_N+1],t[MAX_M+1]; //从下标1开始使用,所以额外加一
int dp[MAX_N+1][MAX_M+1];
while(scanf("%d %d",&n,&m)==2){
getchar(); //吸收回车
for(int i=1;i<=n;i++){
s[i]=getchar();
}
getchar(); //吸收回车
for(int i=1;i<=m;i++){
t[i]=getchar();
}
getchar(); //吸收回车
//初始化dp数组
for(int j=0;j<=m;j++){
dp[0][j]=0;
}
for(int i=0;i<=n;i++){
dp[i][0]=0;
}
//根据递推式完善dp数组
for(int i=0;i<n;i++){
for(int j=0;j<m;j++){
if(s[i+1]!=t[j+1]){
dp[i+1][j+1]=(dp[i][j+1]>dp[i+1][j])?dp[i][j+1]:dp[i+1][j];
}
else{
int temp=(dp[i][j]+1>dp[i+1][j])?dp[i][j]+1:dp[i+1][j];
dp[i+1][j+1]=(temp>dp[i][j+1])?temp:dp[i][j+1];
}
}
}
//根据dp数组回答问题
printf("%d\n",dp[n][m]);
}
return 0;
}
五、最长上升子序列
【题目描述】
图片来自《挑战程序设计竞赛(第2版)》
这道题和上道题题目很像,但意思完全不同。依旧先分析实现方法:
1、dp[i+1]代表以a[i]结尾的最长上升子序列的长度;
2、初始化dp[0]和dp[1]为0;
3、递推公式是:
d
p
[
i
]
=
m
a
x
{
1
,
d
p
[
j
]
+
1
∣
j
<
i
且
a
[
j
]
<
a
[
i
]
}
dp[i]=max\{1,dp[j]+1|j<i且a[j]<a[i]\}
dp[i]=max{1,dp[j]+1∣j<i且a[j]<a[i]}
【示例代码】
#include<stdio.h>
#define MAX_N 1000
int main(){
int n;
int a[MAX_N];
int dp[MAX_N+1];
int current;
while(scanf("%d",&n)==1){
for(int i=0;i<n;i++){
scanf("%d",a+i);
}
//初始化dp数组
dp[0]=0;
dp[1]=1;
//完善dp数组
for(int i=2;i<=n;i++){
dp[i]=dp[i-1];
for(int j=i-2;j>=0;j--){
if(a[i-1]>a[j]){
current=dp[j+1]+1;
}
else{
continue;
}
dp[i]=(dp[i]>current)?dp[i]:current;
}
}
//利用dp数组回答问题
printf("%d\n",dp[n]);
}
return 0;
}
六、有关计数问题的dp——划分数
【题目描述】
将题目中“模M的余数”去掉,我们直接输出划分方法总数。
图片来自《挑战程序设计竞赛(第2版)》。
实现方法分析:
1、dp[i][j]代表j划分为不超过i组的种数;
2、初始化dp[0][非零]为0,因为没有非零数能被划分为不超过0组,dp[…][0]为1,0被划分为任意多组方式都为一种;
3、j划分成不超过i组,可以等价为将j划分为i组,然后每一组的值可以为0。这样的话就假设j划分成的i个数都为非零和至少一个零两种情况,前者等于dp[i][j-i],后者等于dp[i-1][j],所以递推式如下:
d
p
[
i
]
[
j
]
=
d
p
[
i
]
[
j
−
i
]
+
d
p
[
i
−
1
]
[
j
]
dp[i][j]=dp[i][j-i]+dp[i-1][j]
dp[i][j]=dp[i][j−i]+dp[i−1][j]
(
i
>
=
1
,
j
>
=
1
)
(i>=1,j>=1)
(i>=1,j>=1)
⚠️注意不要将dp[i][j-i]写成dp[i][j-1]
【示例代码】
#include<stdio.h>
#define MAX_M 1000
#define MAX_N 1000
int main(){
int n,m;
int dp[MAX_M][MAX_N];
while(scanf("%d %d",&n,&m)==2){
//初始化dp数组
for(int i=0;i<=m;i++){
dp[i][0]=1; //将0划分成任意多组,方式都只有一种
}
for(int j=1;j<=n;j++){
dp[0][j]=0; //任何正数都无法划分成0组
}
//利用递推公式完善dp数组
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
// dp[i][j]=dp[i-1][j]+dp[i][j-1]; //错误写法
dp[i][j]=dp[i-1][j]+dp[i][j-i];
}
}
//利用dp回答问题
printf("%d\n",dp[m][n]);
}
return 0;
}
小结
把以上几道题背会,足以掌握动态规划的基本方法,也足以举一反三地应对简单一些的赛事和考试。对于高级赛事,仍需要多练习,感悟为主。