今天又是长者给我们讲小学题目的一天
长者的讲台上又是布满了冰红茶的一天
-------------------------------------------------------------------------------------------------------------------------------------
正片开始
动态规划
动态规划是个抽象的东西。
接下来的例子小部分可能会比较搞笑
我们先来看一个严肃的例子,来认识一下什么是DP:
斐波那契数列:
大家都知道斐波那契数列是个啥吧
就是这个: f(0)=0,f(1)=1,f(n)=f(n-1)+f(n-2)
它和DP有什么关系呢?
1.都要有边界条件(这里就是f(0)=0,f(1)=1)
2.都有转移方程(这里的是f(n)=f(n-1)+f(n-2))
3.都有当前状态(这里就是f(n))
这样我们也就总结出来了做DP要注意的东西:
1.定义状态
2.定义边界
3.转移方程
以上也是做DP的步骤。
代码的几种写法:
顺着推,逆着推,记忆化搜索
//以斐波那契数列为例的三种写法 int dfs(int n) { if(n==0)f[0]=0; else if(n==1)f[1]=0; if(vis[n])return f[n]; vis[n]=true; f[n]=dfs(n-1)+dfs(n-2); return f[n]; } int main() {int n,f[100]; //逆着推(无优化) vis[0]=1;vis[1]=1; cin>>n; f[0]=0; f[1]=1; for(int i-2;i<=n;i++) f[i]=f[i-1]+f[i-2]; cout<<f[n]; //顺着推 cin>>n; f[0]=0;f[1]=1; for(int i=0;i<n;i++) {f[i+1]+=f[i]; f[i+2]+=f[i]; } //记忆化搜索 cin>>n; cout<<dfs(n)<<endl; }
noip的考纲虽然比宇宙还大,但是总会有那么几种常考的东西。
常考DP:
数位DP
树形DP
区间DP
状压DP
其它DP(这个是最常考的)
一.数位DP
举个栗子
读入两个正整数l,r,求从l到r之间一共有多少个数?
ans=r-l+1
但是只有这么一句不优雅,所以我们要用数位DP做。
ans=[0,r]的数-[0,l]的数
这样我们就把问题转化为求[0,x]区间上的数了。我们先把x的十进制表示出来。
其中X0代表个位,X1代表十位.....以此类推
我们这个题实际上是求满足0<=v<=x的v
我们对v进行相同的操作
如果当v不足n位时,就是有前导0.我们在每一位上填上0~9之间的一个整数,看满足要求的v有多少。我们从高位开始填。那这就会有很多种情况对不对?我们举个例子分析一下
假如我们要填Vn-3,那么按照从高位开始填的顺序,Vn,Vn-1,Vn-2已经填好了。我们把每个Vi和每个Xi比较一下。
我们把这些数分到两边。
①.左边>右边 那Vn-3就随便填了
②.左边<右边 填的Vn-3就必须<=Xn-3,当然这里还有个坑,我们待会程序里讲
我们用f[i][j]表示已经填到了第i位,j表示V的第i-1位及以前是否等于X的i-1位及以前,这种情况下的方案数。(0是不等于,1是等于),ans=f[0][0]+f[0][1]。
转移:对于每个f[i][0],f[i][0]=9*f[i-1][0]+(X(n-i)-1)*f[i-1][1](后面这一部分是指前i-1位都等于,第i位不等于)
f[i][1]=f[i-1][1],因为永远等于x的数不会变
边界:f[n+1][1]=1 因为Vn+1=Xn+1=0
int f[100][2]; int solve(int x) { int z[100]; int n; while(x) {z[n++]=x%10; x/=10; } n--; memset(f,0,sizeof(f));//要记住清空以防意外 f[n+1][1]=1;//Xn+1与Vn+1都是0,所以是等于(顶上界)的情况 for(int i=n;i>0;i--) for(int j=0;j<=1;j++) { if(j==0) { for(int k=0;k<=9;k++) f[i][0]+=f[i+1][j];//把f[i][j]的方案数加到f[i][0]上 (注意不是+9) 用循环的方式加起来方便以后改程序 } else { for(int k=0;k<=z[i];k++) {if(k==z[i])f[i][1]+=f[i+1][j];//如果顶上界,就加到f[i][1](顶上界的数组)(左边=右边)里面 else f[i][0]+=f[i+1][j];//如果当前位填的数<z[i],就说明不顶上界,就加到f[i][0]里面 } } } return f[0][0]+f[0][1]; } int main() {int l,r; cin>>l>>r; cout<<solve(r)-solve(l-1)<<endl; }
上面说的顶上界就是Vi=Xi的情况(私下的称呼,懒的改了)
绕了好大一圈解决了一个简单题
数位之和:例如123,数位之和=1+2+3=6
这里把上一个题改一改,再加一个数组记录数位之和即可(i,j的意义不变)
int f[100][2];//方案数之和,j=0:不顶上界,j=1:顶上界 int g[100][2];//数位之和 int solve(int x) { int z[100]; int n; while(x) {z[n++]=x%10; x/=10; } n--; memset(f,0,sizeof(f)); memset(g,0,sizeof(g)); f[n+1][1]=1; g[n+1][1]=0; for(int i=n;i>0;i--) for(int j=0;j<=1;j++) { if(j==0) { for(int k=0;k<=9;k++)//枚举每个要填的数 {f[i][0]+=f[i+1][j];//每一个方案的后面都+k,那对总的数位之和的贡献就是f[i+1][j](方案数)*k(新填的数) g[i][0]+=g[i+1][j]+f[i+1][j]*k } } else { for(int k=0;k<=z[i];k++) {if(k==z[i]) {f[i][1]+=f[i+1][j]; g[i][1]+=g[i+1][j]+f[i+1][j]*k; } else {f[i][0]+=f[i+1][j]; g[i][0]+=g[i+1][0]+f[i+1][j]*k; } } } } return f[0][0]+f[0][1]; } int main() {int l,r; cin>>l>>r; cout<<solve(r)-solve(l-1)<<endl; }
这个题多了一个限制条件。
多加一个维度肯定能把这题做出来。
-------------------长者
那我们就多加一个维度好了。
状态:因为我们要比较相邻两个数位上的数,又是从高位向低位填写,所以只需要记录当前填的最后一位。
作业1:洛谷P2657windy数(咕咕咕)
树形DP:
我们看一道简单的不行的题
给定一棵有n个点的树,请问这棵树有几个点?
答案是n个点吗?真的是n个点吗?当然是啦
好我们想想这题怎么做
先介绍一下树。
树最上面的点叫根,最下面的点叫叶子结点。
子树:由一个结点向下走,能走到的所有结点构成的树叫以这个结点为根的子树。
有多少个点:以根节点为根的子树的点。f[i]:以i这个点为根的子树的点的个数,这里就要求f[1]。
边界:f[叶子结点]=1
转移:f[p]=f[ls[p]]+f[rs[p]]+1.左儿子+右儿子+1=p结点的子树大小
长者只放了伪代码qwq
对了这个题的答案是n,所以这个题只要读入n,再输出就好辣。
我们来看点有档次的东西
给一个n个点的树,求直径(距离最远的两个点的距离)
首先,每种路径都长这个样子(这里指一个点到另一个点)
我们把它换个方向
是不是顺眼了?这样就符合从一个结点向下走的方向了。
既然每种路径都长这样,那直径一定长这样喽。
从一个点找两种尽量长(最长+次长)的路径拼起来就是以这个点为拐点的最长一条路径。
我们用f[i][0]表示从点i向下走,最长的路径长度,f[i][1]表示从点i向下走,次长路径的长度。
状态转移便是选择儿子。
f[p][0]=max{f[p1][0],f[p2][0]...f[pk][0]}+1求最大值:必定走儿子中路线最长的那个,再加上1(用一 步到达儿子)
f[p][1]:假如我们刚才选了pi,f[p][1]=max{f[p1][0],...(把f[pi][0],f[pi][1]去掉)}+1
ans=f[1][0]+f[1][1];
状压DP
状压就是状态压缩。
为什么要状态压缩呢?
看个例子:
给n个点,坐标分别为(x1,y1),(x2,y2).........(xn,yn)(这不是个图,你可以乱走)一个人在一号点,从1号点出发,把剩下的点至少走一次,使走过的路径最短(TSP问题(中文:旅行商问题))
经典的Np-hard问题(最快也是2^n级别)
1.每个点只去一次
2.最短就是走线段
假如我们已经走了1,2,5
3,4,6每个点都可行。(只是不一定最短)
这里每个走过/没走过都是集合,so我们怎么表是成数组下标?
这里就是状态压缩了(集合--->数)。用二进制即可完成。
构造n位(元素的个数)二进制数,每位用0/1来表示某个元素是否在集合里。(0为不在,1为在)
例如这个二进制数与{1,4,5}等价.
TSP问题:f[s][i],s为n位二进制数,(已经走过的点所对应的集合)已经走过s里的所有点,现在停留在i点的最小距离。
边界:f[1][1]=0.
枚举要走的点j。(要保证j不属于s这个集合)
--->f[ { s∪j } ][ j ]=f[ s ][ j ]+dis[ i ][ j ](从点i走到点j)
转移:实际上是把走过的点的元素变多==>把二进制上的0不断边1==>s变大
所以按s从小到大枚举
//状压dp int n,f[100],x[100],y[100]; double dis(int x,int y)//算距离 { return sqrt((x[x]-x[y])*(x[x]-x[y])+(y[x]-y[y])*(y[x]-y[y])); } double f[1<<maxn][maxn];//s表示一个集合转换成二进制数,一个n个点,所以最大为2^n int main() {cin>>n; for(int i=0;i<n;i++)//0~n-1方便二进制 cin>>x[i]>>y[i]; for(int i=0;i<(1<<n);i++) for(int j=0;j<n;j++) f[i][j]=1e+20; f[1][0]=0;//为了对应二进制,起点变为0 for(int s=1;i<(1<<n);s++)//枚举全部可能的点的集合 {for(int i=0;i<n;i++)//枚举停留点,要注意is是否在s里面 {if((s>>i)&1)//判断也就是s的第i位是否是1 {for(j=0;j<n;j++)//枚举要走哪个点 if(((s>>j)&1)==0)//j不能走过 f[s|(1<<j)][j]=min(f[s|(1<<j)][j],f[s][i]+dis(i,j)); //f的第一维就是把j放到s集合里。 } } } //最后枚举所有可能停的点中最小的一个 double ans=1e+20; for(int i=0;i<n;i++) ans=min(ans,f[(1<<n)-1][i]);//n个1组成的所有的数:2^n-1 cout<<ans<<endl; }
说点数据范围的事儿
n<=20(22):状压 n<=12:O(n!) n<=32(50)直接放弃 (来自长者的建议) n<=100:n^3(Floyd,区间) n<=1000:O(n^2) n<=10^5:数据结构 n<=10^6:O(n) n>10^6:O(1)/O(log n)
区间DP:
合并石子:有n堆石子,可以合并相邻两堆。合并代价是这两堆的数目之和。把n堆石头合并为一堆,求最小代价。
把相邻的两堆合为一堆:区间dp
区间dp形式:f[l][r]代表把第l堆石子到第r堆石子合并为一堆的最小代价,求f[1][n]
边界:f[i][i]=0
转移:因为最后一次合并,一定是把某堆和另一堆合并为一堆。合并时不改变原来石头的顺序,所以合并的那两堆一定对应某个分界线,如下图:
左边那一堆:f[l][p],右边那一堆:f[p+1][r].
so f[l][r]=min(f[l][p]+f[p+1][r]+sum[l][r])注意别忘加黄色部分。(区间和)这里我们枚举p)
int f[100][100]; int main() {int n,z[100]; cin>>n; for(int i=1;i<=n;i++) cin>>z[i]; make_sum(); memset(f,0x3f,sizeof(f));//将f数组初始化为非常大的数 (最好不用0x7f,因为两个加起来爆int) for(int i=1;i<=n;i++) f[i][i]=0;//只有一堆:代价为0 //我们需要枚举左端点,右端点,断点 for(int l=1;l<=n;l++) for(int r=l+1;r<=n;r++)//r从l+1开始,因为r=l算完了 for(int p=l;p<r;p++) f[l][r]=min(f[l][r],f[l][p]+f[p+1][r]+sum[l][r]);//sum[l][r]用前缀和算一下 //好了上面的那段是错的,但是为什么? //问题在于执行顺序。在算f[1][r]时f[2][r],f[3][r]....都没有算过 //每一个大区间都由两个小区间得来,所以我们在最外层枚举区间长度 for(int len=2;len<=n;len++) for(int l=1;r=len;r<=n;l++,r++)//区间整体向右移动 for(int p=l;p<r;p++) f[l][r]=min(f[l][r],f[l][p]+f[p+1][r]+sum[l][r]); cout<<f[1][n]; //复杂度O(n^3),so n也就最多200多 }
如果我们把这n堆弄成个环,改怎么弄呢
a1,an 相邻处理方法:
ans=min(f[1][n],f[2][n+1])
但是a1,a2就不相邻了.....
考虑考虑,全部加完之后:
最终答案ans=min(f[i][n+i-1])
为什么呢?因为把一个环合成一个点,一定会有一条边用不到(合并相当于减少一条边)那我们把那条用不掉的边去掉。这里选择答案的过程就是枚举去掉哪条边
一些普通DP
我们用 f[i][j]:到第i行第j列的方案数
边界: f[1][j]=f[i][1]=1
转移:f[i][j]=f[i-1][j]+f[i][j-1];
小结论:左上角-->右下角
一共n+m-2步,向右走n-1步
每次向下/右下走
权值:路径上所有数的和。要找权值最大的路径的值
f[i][j]:从(1,1)走到(i,j)的最大权值
f[1][1]=a[1][1]
f[i][j]=max(f[i-1][j-1],f[i-1][j])+a[i][j]从左上和正上走过来
改造版:
找到一条路径,使这条路的权值之和在模m之后最大
n,m<=100 数据比较x小---->给大了做不了/m也是个维度
(之前最优解不一定是之后最优解--->加维度)
f[i][j][k]:
1,1到i,j 的权值和mod m=k是否可能存在
因为走到i,j之前的权值之和是k-aij,所以f[i][j][k]=f[i-1][j-1][(k-aij)mod m ] or f[i-1][j][k-aij]
边界:f[1][1][a11%m]=1
ans=最后一行最大的可能的k
f[i]表示以ai结尾的最长上升子序列的长度
枚举i前面的j<i,aj<ai,f[i]=max(f[j])+1
O(n^2)(T掉一半)
数据增强到10^5:用线段树。f[i]=max(f[j])+1(1<=j<i,aj<ai)
假设v=max(a1.........an)
建一棵长度为v的线段树
按值左右划分,所有f[i]<ai(对应的f)的数都在ai左边按照f[i]大小排序
轻松找出fj最大值,+1得到fi,然后修改,利用线段树进行区间询问最大值和单点修改
稍微特殊一点的DP:
背包:背包九讲qwq
01背包: 有n个物品,第i个的价值为wi,体积为vi.有一背包大小为m,在放入的物品体积之和不超过m的情况下,价值之和最大。这是最基本的问题。叫01背包是因为每个物品要么放进背包,要么不放进背包
f[i][j]表示判断完第1到i个物品,占用j体积的最大价值。
f[ i+1 ][ j ](第i+1个物品不放)=f[ i ][ j ]
f[ i+1 ][ j+v[ i+1 ] ](第i+1个物品放)=f[ i ][ j ]+w[ i+1 ]
完全(无穷)背包:有n个物品,第i个的价值为wi,体积为vi.有一背包大小为m,每种物品无数个,问最大权值。
把循环倒过来qwq
策略1:枚举每种用多少个 f[i][j]+x*w[i+1]àf[i+1][j+x*v[i+1]]
复杂度:
策略二:消掉枚举:f[i][j]=max(f[i-1][j],f[i][j-v[i]]+w[i])
第i个物品选0个 又选了一次第i个物品
告诉每个物体有多少个:直接枚举
一些练习:
用数位DP。
显然要加一维
第三维记录乘积
但是k最大到9^18,总之空间会炸。
k不能为11(1~9的质因子只有2,3,5,7)
k可以写成如下形式:
so,
虽然它有6维,但它不会炸
因为a+b+c+d<=log210^18+log310^18+log510^18+log710^18