前言
动态规划(以下部分简称DP)是在算法竞赛中常见的,也不容易得分的一类题目,问题类型很多,本文主要讲述背包问题、区间DP、数位DP、树形DP、状态压缩DP(以下简称状压DP)、单调队列优化DP的内容。
如果在考场上遇到DP题目,还是先打出搜索暴力最好,这样我们可以在考虑DP的时候有一个可以进行对拍的程序,还是为了不爆零。
本文所有例题的代码示例
更新日志
- $2021.10.6$ 进行了矫正和补充。
- $2021.11.2$ 深化了 区间DP 内容的总结。
目录
$1.1$ 背包问题
-
$1.1.1$ $0/1$背包问题
-
$1.1.2$ 完全背包问题
-
$1.1.3$ 多重背包问题
-
$1.1.4$ 多重背包问题的二进制拆分
$1.2$ 区间DP
-
$1.2.1$ 石子合并
-
$1.2.2$ 环形石子合并
$1.3$ 数位DP
$1.4$ 树形DP
-
$1.4.1$ 树的最长距离
-
$1.4.2$ 树形DP与背包问题结合
$1.5$ 状压DP
$1.6$ 单调队列优化DP
-
$1.6.1$ 围栏
-
$1.6.2$ 跳房子
$1.1$ 背包问题
$1.1.1$ $0/1$背包问题
$0/1$背包问题是一个非常典型的动态规划问题,同时也是其他背包问题的基础。
动态规划是不断决策求最优解的过程,在$0/1$背包问题中,这个过程体现在是不断对第$i$个物品的做出决策,$0/1$正好代表不选与选两种决定上。
题意介绍
有$N$件物品和一个容量是$V$的背包。每件物品只能使用一次。
第$i$件物品的体积是$v_i$,价值是$w_i$。
思路讲解
-
首先我们定义一个二维数组$f_{(i,j)}$,表示仅仅考虑前$i$个物品,总使用体积为$j$时的最大价值。
-
然后我们去考虑如何转移状态:
一、初始化:$f_{(0,0)}=0$(原因:不考虑任何物品,总使用体积为0的价值一定为零),其余全部为$-\infty$(原因:为了方便我们用$max$函数更新状态的最大值)。
二、状态转移(共三种情况,假设本处要转移$f_{(i,j)}$的状态)
-
若空间不够($j<v_i$),则这个物品一定不能选择,因而此时$f_{(i,j)}=f_{(i-1,j)}$。
-
若空间充足($j\ge v_i$),则这个物品可以选择,也可以不选择,因而此时$f_{(i,j)}=max(f_{(i-1,j)},f_{(i-1,j-v_{i})}+w_i)$。
-
本处不贴代码实现。
- 那么我们可以发现$f_i$这一维度中的所有状态都来自于$f_{i-1}$,因而我们可以省去第一维,同时更改枚举顺序。观察到二维状态转移方程中$f_{(i,j)}$要么由上一维中的$j$得到,要么由上一维中$j$靠前的位置得到,因而我们可以逆序枚举,以保证每个$j$更新时,它所来自的状态仍处于上一维度的状态中。因而,我们得到的一维状态转移方程如下:
$\ \ \ \ \ \ \ \ f_j=max(f_j,f_{j-v_i}+w_i)$(其中$j$需要逆序循环)
总时间复杂度 $O(NV)$
$1.1.2$ 完全背包问题
完全背包问题同样是一个非常典型的动态规划问题。
题意介绍
有$N$件物品和一个容量是$V$的背包。每件物品可以使用无限次。
第$i$件物品的体积是$v_i$,价值是$w_i$。
思路讲解
-
思路很简单,就是将完全背包问题转化为$0/1$背包问题。根据题意,我们可以看到“每件物品可以使用无限次”,但是在本质上,并没有如此大的空间可以让我们使用无限次,因而我们可以将每个物品拆分成$\lfloor \frac{j}{v_i} \rfloor$个来进行$0/1$背包运算。本思路不再多讲。
-
实际上,上面的思路并不能够在规定的时间复杂度中求出答案,那么我们开始考虑,本质上,完全背包问题就是无限的$0/1$背包问题。那么在同样的一维状态下,某个物品可以放无限个,那岂不是$f_j$可以来自现在这一维的状态了?
-
因而,我们就可以写出完全背包问题的状态转移方程。
$\ \ \ \ \ \ \ \ f_j=max(f_j,f_{j-v_i})+w_i$(此处的$j$需要正序循环)
总时间复杂度 $O(NV)$
$1.1.3$ 多重背包问题
多重背包问题本质上是上面两个背包问题的结合。
题意介绍
有$N$件物品和一个容量是$V$的背包。
第$i$件物品的体积是$v_i$,价值是$w_i$,可以使用$s_i$次。
思路讲解
- 很简单的结合,对于每件物品,进行$s_i$次循环,即将第$i$个物品拆成$s_i$个相同的物品进行$0/1$背包问题求解即可。
总时间复杂度 $O(NV\sum^{N}_{i=1}s_i)$
$1.1.4$ 多重背包问题的二进制拆分
上述求解多重背包问题的总时间复杂度过高,对于大数据无法在规定时间内求解,因而我们考虑怎样去优化多重背包问题。
思路讲解
-
通过对整个代码的通读以及我们对于前面背包问题的理解,我们发现,这个求解过程中唯一可以优化的点就在于将一个物品拆分成$s_i$个相同物品的时间复杂度
-
由于每个物品被拆成了$s_i$个,我们本质上的总目的是为了保证拆出来的方案可以包含由$1$到$s_i$的所有方案,由于任何一个数字都可以用二进制表示,因而我们想到二进制拆分。
-
我们将每个物品拆分成所有的$s_i$拆分成小于$s_i$的$2$的非负数次幂和$s_i-\sum_{i=0}^{2^i<s_i}2^i$的物品,拆分出的物品的体积和价值也根据它代表原物品的数量来相乘,最终我们就将$s_i$拆分成了$\log_{2}s_i+1$个物品。然后再进行$0/1$背包问题求解即可。
总时间复杂度 $O(NV\ \log_{2}\sum^{N}_{i=1}s_i)$
除了背包问题的模型比较固定,求解过程和思路都可以用同一种方法之外,其余的DP就相比灵活许多,因而难以具体描述,只能通过做题来自我总结。
本文中,每种DP最多举两道例题来讲述,最少不会举例题,但是本人总结的大致思路都会在本文中出现。
$1.2$ 区间DP
区间DP有很典型的例题,本文中举最典型的石子合并和环形石子合并(点击有链接)来讲解。
$1.2.1$ 石子合并
石子合并是最简单的一类区间DP。(本处同样有题目链接)
题意介绍
设有$N$堆石子排成一排,其编号分别为$1,2,3,···,N$。
每堆石子有一定的质量$w_i$,现在要将这$N$堆石子合并成为一堆。
合并规则:我们每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻。(注意:合并时由于选择的顺序不同,合并的总代价也不相同。)
现在请你找出一种合理的方法,使得将这些石子合并为一堆的总代价最小,并将该最小值输出。
思路讲解
-
通过对题目的反复阅读,我们可以发现,最终的答案一定来自于这样的两段(由最左边为左端点连续子段 与 由最右边为右端点的连续子段,且这两个子段的右端点与左端点为同一个点)。那么我们可以使用分治的思想,先将问题分解为求 某个左端点为起点的连续子段+某个右端点为重点的连续子段 的最小总价值。以此类推,我们得到了我们的思路。
-
根据我们上面的分析,我们定义数组$f_{(i,j)}$,表示合并第$i\sim j$堆石子的最小花费。于是我们开始考虑状态转移方程:
-
当$i=j$时,此时无需合并,因而$f_{(i,j)}=0$。
-
当$i>j$时,不存在这样的情况。
-
当$i<j$时,我们可以依次枚举分界点$k$,因而可以得到$f_{(i,j)}=min(f_{(i,j)},f_{(i,k)}+f_{(k,j)}+s_j+s_{i-1})$(其中$i\le k\le j$,$s$数组为前缀和数组)
-
-
最终答案:$f_{(1,N)}$
$1.2.2$ 环形石子合并
环形石子合并是一个环形的区间DP。(本处同样有题目链接)
题意介绍
设有$N$堆石子排成一圈,其编号分别为$1,2,3,···,N$。
每堆石子有一定的质量$w_i$,现在要将这$N$堆石子合并成为一堆。
合并规则:我们每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻。(注意:合并时由于选择的顺序不同,合并的总代价也不相同。同时,由于是环形摆放的,因而第$1$堆与第$N$堆相邻)
现在请你请编写一个程序,读入堆数$n$及每堆的石子数,并进行如下计算:
-
选择一种合并石子的方案,使得做$n-1$次合并代价总和最大。
-
选择一种合并石子的方案,使得做$n-1$次合并代价总和最小。
思路讲解
-
刚才我们讲完了线性的石子合并,那么对于环形的题目,我们同样可以使用一种方法来解决该类问题。即直接将环撕开,并将其倍长复制一遍。
-
我们使用与刚才相同的状态定义,此处只讲解求最小代价,最大代价以此类推即可。同样,$f_{(i,i)}$无需合并,因而$f_{(i,i)}=0$.
-
那么对于$f_{(i,j)}$,我们可以得到$f_{(i,j)}=min(f_{(i,j)},f_{(i,k-1)}+f_{(k,j)}+s_j-s_{i-1})$.
-
此时我们发现,这个问题就转化为了上面的线性问题。最终答案$ans=min\{f_{(l,r)}\}$,其中$r-l+1=n$.
总的而言,我们发现区间DP的套路就是枚举中转点$k$,然后进行状态转移,我们可以用下面这段代码来作为区间DP的核心代码。
memset(f,0x7f,sizeof f);
for(int i=1;i<=n;i++) f[i][i]=0;
//给f数组赋初值
for(int len=2;len<=n;len++)//枚举区间长度
for(int i=1;i+len-1<=n;i++)//枚举区间起点
{
int j=i+len-1;//找到区间终点
for(int k=i;k<j;k++)//枚举中转点
{
f[i][j]=max(f[i][j],f[i][k]+f[k+1][j]+w[i][j])//状态转移
//或者min,因题而异
//w[i][j]表示花费
}
}
那么我们一般在什么情况下回去考虑使用 区间DP 呢?一般情况下,我们在时间复杂度允许次方级别的时候(例如2000允许平方),或者是题目明确表述了区间操作(例如区间合并,括号序列等)的多种操作(实质上就是表述状态转移方程),这些时候,题目的正解一般就是 区间DP。不然的话,也可能会出现 区间DP 部分分的情况。
$1.3$ 状压DP
状压DP借助二进制,将原本的状态进行压缩,压缩成$0$或$1$,从而将原本需要的大量空间压缩到一个数字中,优化时间和空间复杂度。因而,状压DP通常涉及位运算,以下是几种常用的位运算。
用途 | 操作方式 |
---|---|
将第$i$位上的数字修改为$1$ | x=x|(1<<i-1) |
将第$i$位上的数字修改为$0$ | x=x&~(1<<i-1) |
查询第$i$位上的数字是否为$1$ | x&(1<<i-1)!=0 |
由于状压DP的思路与代码都很长,一道题目讲解起来可能就会让该节篇幅超过区间DP,因而本处不再举出例题,仅是总结大致方向。
思路总结
-
状态定义:状压DP由于是状态压缩,因而在状态定义时,往往有一维是状态。另外,由于状压DP大多数题目要求求的是方案数,因而我们往往需要存储到什么地步,状态是什么时,方案数是多少。
-
状态转移:状压DP的状态转移,主要是由可以走到该状态某个状态转移到的,其与上述模型的差距只在于,此处的状态经过压缩,需要用二进制位运算寻找下一步或上一步的状态,本质上并无二致。
-
另外,状压DP中,被压缩的状态不一定只有一个,例如NOI$2001$炮兵阵地这道经典题目中,我们压缩的状态是上面两行。
$1.4$ 树形DP
树形DP是DP中比较特殊的一个,它建立在树上进行DP。由于树本身固有的递归性质,我们一般使用递归的方式进行树形DP。某些情况下,我们同样可以使用循环。
树形DP可以有两种转移方向,一种是从父节点转移,另一种是从子节点转移。通常,我们使用后者。
$1.4.1$ 树的最长距离
本题是树形DP的典型例题。
题意介绍
给出一个以$1$为根的$n$个结点的树,树边有权值,求出每个结点与相距最远结点间的距离$s_i$。
输入包括多组数据。对于每组数据,先输入一个正整数$n$,表示结点个数。之后输入$n-1$行,每行包括两个整数$a,b$。其中,在第$i$行(注意:此处行数指总行数),$a$表示第$i$个节点的父节点,$b$表示该点到其父节点的路径长度。因而,一号节点一定是该树的根节点。
对于每组数据,输出包括$n$行,分别表示第$i$个节点与相距最远节点之间的距离$s_i$。
由于本题并没有在$AcWing$题库中,所以大家可以与文章开头的代码专篇中的代码进行对拍来检验代码的正确性。对您造成的不便我深表歉意。
思路讲解
-
我们考虑对于某个节点$u$,与它距离最远的点可能在以节点$u$为根节点的子树中,或者在该子树意外。我们发现,这个点一定由$u$向上延伸,或者向下延伸。因而我们创建数组$f_{(x,0/1/2)}$,表示由$x$出发,向下的最大距离/向下的次大距离/向上的最大距离。
-
向下的最大距离为:$f_{(u,0)}=max\{f_{(y,0)}+w_{(u,y)}\},y\in son_u$
-
向下的次大距离为:$f_{(u,1)}=$ 次大值$\{f_{(y,0)}+w_{(u,y)}\},y\in son_u$
-
向上的最大距离为:
$f_{(y,2)}=max\{f_{(u,1)},f_{(u,2)}\},y\in subtree_u$
$f_{(y,2)}=max\{f_{(u,0)},f_{(u,2)}\},y\notin subtree_u$
-
-
那么,对于每个节点$u$,我们可以得到$s_x=max\{f_{(x,0)},f_{(x,2)}\}$
-
最终答案 $ans=max\{s_i\},1\le i\le N$ ($N$为总点数)
-
总时间复杂度 $O(N)$
$1.4.2$ 树形DP与背包问题结合
这类问题也可以称为树形背包问题。例题:选课(有链接)
本题是蓝书原题,书上有讲解,因而就不在此处赘述了。
总而言之,树形DP通常使用递归,部分情况下,还可以使用记忆化搜索(很少很少)
$1.5$ 数位DP
数位DP的思路和代码,以及状态转移方程都很长,并且并没有很具代表性的题目,因而本节不举例题。
数位DP通常使用记忆化搜索,记录已经求出过的状态,而由于不同题目的不同数据的数字限制,我们通常需要进行判定,总的而言,大致代码如下:
ll solve(int pos,int last,bool limit)
{
if(pos==-1) return 1;
if(!limit&&f[pos][last]!=-1) return f[pos][last];
//pos记录当前需要填写的位置
//last记录上一位填写的数字(可以不需要,因题而异)
//limit表示是否不能将所有情况枚举
int maxn=limit?c[pos]:9;
//如果不能将所有情况枚举,那么只能枚举当前可以枚举的最大数字,不然可以枚举0-9
ll ans=0;
for(int i=0;i<=maxn;i++)
{
//进行递归操作
}
if(!limit) f[pos][last]=ans;
//如果没有受限制,那么此时求出来的解适用于全局
return ans;
}
$1.6$ 单调队列优化DP
单调队列优化DP的最典型例子是滑动窗口,本处不做赘述,有需要者可以去复习一下。(链接)
有关于单调队列优化DP,其实也就是滑动窗口的应用了,思路都很简单,但是本处只举出例题来,在开头链接处放上代码,不再讲解。
$1.6.1$ 围栏
$1.6.2$ 跳房子
最后,感谢您的感谢阅读!同时对于本文中出现的任何问题,和本文引申链接中代码的错误,欢迎在下面评论区指正。对于阅读本文时出现的任何问题,欢迎在评论区提问。