含义:DP是对一个抽象后的最优方案的解决办法,尤其是在初学阶段,处理多阶段的复杂过程的问题时,这是我们应该掌握的方法
那什么时候采取用呢?我感觉好多问题都好复杂呀
使用条件:(1)最优化的原理:也就是把每一个子的结构和模块最优化
在解决问题中,对这个问题的一个环节作出一系列操作使其最优,然后剩下的各个环节都是和这一个环节性质相同处理方法一致的问题,只是数据有不同而已
(2)无后效性:我们只要去关注一个子结构、子问题的一个最优解,能够不去考虑其他的序列,途径等等,子问题的决策的序列都不会影响最终问题的解决
步骤:
第一:确定状态:状态是一个数学和算法的式子,能够把解决问题所需要的元素包含
第二:状态转移方程和边界条件:递归的定义状态的最优解,将一个问题转移到原问题性质一样的问题
第三:就是程序实现啦,迭代啊记忆化搜索等等。
第一个例子啦:数字金字塔
【题目描述】
观察下面的数字金字塔。写一个程序查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以从当前点走到左下方的点也可以到达右下方的点。
【输入】
第一个行包含R(1≤ R≤1000),表示行的数目。
后面每行为这个数字金字塔特定行包含的整数。
所有的被供应的整数是非负的且不大于100。
【输出】
单独的一行,包含那个可能得到的最大的和。
【输入样例】
5 13 11 8 12 7 26 6 14 15 8 12 7 13 24 11
【输出样例】
86(在上面的样例中,从13到8到26到15到24的路径产生了最大的和86。)
第一种方法:单纯的搜索,无视栈的溢出
既然是初学者版,那就还是简单提一下递归
含义:如果一个函数在其函数体中直接或间接地调用了自己,则该函数称为递归函数
过程:把一个递归函数看成多个同名的函数,然后按函数的嵌套调用来理解递归调用过程。递归函数的局部变量(包括形参)在不同的递归调用中 具有不同的实例:
思想:Divide and Conquer
对每一个复杂地问题分解成一个个有关联的小问题通过解决一个小问题去解决其他
关键:通过思考给定结束,进入的条件,这往往是比较难的需要多想。
还有就是递归和循环他们两个其实是密不可分的,这里就不过多叙述,当然有个明显区别就是者两个模式的变量组是不一样的
#include<bits/stdc++.h>
using namespace std;
const int MAXN=100;
int A[MAXN][MAXN];
int n;
int ans;
void dfs(int x,int y,int cur)
{
if(x==n)
{
if(cur>ans)
{
ans=cur;
}
return;//这个位置很关键否则就死了
//这是void函数,与我们待会要讲的尾递归不同,return的位置和判断是void递归的关键
}
dfs(x+1,y,cur+A[x+1][y]);
dfs(x+1,y+1,cur+A[x+1][y+1]);
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=i;j++)
{
cin>>A[i][j];
}
}
ans=0;
dfs(1,1,A[1][1]);
cout<<ans<<endl;
return 0;
}
这个应该可以理解吧?
However:我们都知道栈会溢出啊,这个速度太慢啊等等所以这个方法的缺点大家都明白,
那这里啰嗦一下:为什么会出现这种情况嘞?
啊,因为其实我们这样的全部遍历,会出现重复,我们每一个个点都可以从它的左上和右上的点走到它,那么意味着这个点其实被搜索过了两次。
那我们只要第一次遇到这个数据时,记录一下它的数据,第二次再碰到就直接用这个值就好啦。
那在此之前我们还要介绍一个重要的递归--“尾递归”
其实作为初学者,也会看过一些递归的题目,大家有不有细心地发现有些递归的函数是int 有些是void,或许我们看过理解一遍就过去了,并没有多想,但其实这里面有点东西滴。
尾递归的优势与核心:尾递归调用是本次调用的最后一步操作,所以递归调用时可多次用本次调用的栈空间,并且稍微处理可以成为我们“最好的迭代子”
所以,第二种方法:记忆化搜索
先贴一个代码
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1000;
int n,ans;
int A[MAXN][MAXN],F[MAXN][MAXN];
//F是用来记忆的,所以下面为什么dfs是int 因为要返回值;
int dfs(int x,int y)//其实就是尾递归
{
if(F[x][y]==-1)
{
if(x==n)
{
F[x][y]=A[x][y];
}
else
{
F[x][y]=A[x][y]+max(dfs(x+1,y),dfs(x+1,y+1));
}
}
return F[x][y];
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=i;j++)
{
cin>>A[i][j];
F[i][j]=-1;
}
}
dfs(1,1);//我们是从头递归到底,但其实执行的数据又是从底到上,体会一下
cout<<F[1][1]<<endl;
return 0;
}
最最最最最重要的先解释:F[][],这个是用来记录的,可是怎么记录呢?
这和我们的方法选择有很大的关系,递归,动态规划的思想重于抽象,我们不要陷入思考具体过程的陷阱,要抽象出这个过程,这里F指从(x,y)走到终点路径的最大数值的和。
我们只要一开始把F都初始化为比如-1表示都没被搜索过,搜过那就把值赋给他。
递归的一个底部条件就是X=N时,把他们的值都赋给F
dfs则把问题分为向左下走,向右走。
中中中终终终终于到主题了-——动态规划
第三种方法:动态规划和它的跟班——迭代法。
正经地给大家分析一波,
1.确定状态:定义F[x][y]表示从(1,1)出发到(x,y)的路径中最大的数值的和(小心哦,这里的F又和上面记忆化搜索的不同,这个我们自己选择就好,然后可能会涉及从上向下走,走下向上走等问题)
2.确定状态转移方程和边界条件:依旧要抽象出这个过程,不要考虑具体怎么走,就只要考虑每次都是向左下向右下
F[x][y]=左上和右上走到(x,y)的最大值加上当前的值=max{F[x-1][y],F[x-1][y-1]}+A[x][y]
然后我们发现这个方法是从1,1开始走,只要给1,1一个值就好了
然后用迭代法,诶,忘记介绍了哈哈哈
那就介绍一下:简单来说就是从一开始一层一层地走下去,我们这里状态分析的过程其实就是迭代啦
含义:迭代法也称辗转法,是一种不断用变量的旧值递推新值的过程,逐次逐层的,不断地累加累乘,比如我们熟知的高中数列题啊,牛顿迭代法。计算机里往往用循环和递归来走。
#include<bits/stdc++.h>
using namespace std;
const int MAXN=10002;
int n,ans;
int A[MAXN][MAXN],F[MAXN][MAXN];
//这里的F是指从1,1走到,x,y的最大权值和
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=i;j++)
{
cin>>A[i][j];
}
}
F[1][1]=A[1][1];
for(int i=2;i<=n;i++)
{
for(int j=1;j<=i;j++)
{
F[i][j]=max(F[i-1][j],F[i-1][j-1])+A[i][j];
}
}
ans=0;
for(int i=1;i<=n;i++)
{
ans=max(ans,F[n][i]);
}
cout<<ans<<endl;
return 0;
}
再补充一个斐波那契的迭代法,大家可以体会体会
#include"bits/stdc++.h"
using namespace std;
int fib(int n)
{
int fib_1=1,fib_2=1;
for (int i=3; i<=n; i++)
{
int temp=fib_1+fib_2;
fib_1 = fib_2;
fib_2 = temp;
}
return fib_2;
}
int main()
{
int n;//表示第n个斐波那契数
cin>>n;
fib(n);
return 0;
}
哦了,这道题结束。
但是我们今天还没结束(狗头)
(还有两道
例二:给定一个序列,求最长不下降子序列的长度。
输入格式
第一行为n,表示n个数
第二行n个数
数据范围n<50000
输出格式
最长不下降子序列的长度
方法一:递归和剪枝
这里的剪枝是:最优性剪枝
所谓最优性剪枝,是在我们用搜索方法解决最优化问题的时候的一种常用剪枝。就是当你搜到一半的时候,已经比已经搜到的最优解要不优了,那么这个方案肯定是不行的,即刻停止搜索,进行回溯。即:有比较,选最优。
推荐:浅谈几种常见的剪枝方式 - Seaway-Fu - 博客园
贴代码~~
分析:递归的函数怎么写,就在于我们这里轮到判断每一个数时,它只有选和不选两个方案,所以设计程序去实现它,同样还要关注实现所需的必要元素,要求长度,那长度要有,选择元素,当前元素要有,最长不下降,那要与之前的数比较,序列的尾数也要有。再把问题独立出来使每一次搜索的本质都是一样的。
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1005;
int n,ans;
int A[MAXN];
void dfs(int last,int cur,int len)//cur是当前判断的数的下标,last是这个子序列最后一个元素
//len是这个子序列的长度
{
if(cur==n+1)//这个和那个数字金字塔简单递归很像体会一下
//等于n+1是因为每次调用都会加1如果大于n了说明我们对这一次子序列的查找就要结束
{
if(len>ans)
{
ans=len;
}
return;//这个再次强调,void的递归的结束一定要弄清楚
}
if(A[cur]>A[last])
{
dfs(cur,cur+1,len+1);
}
if(len+(n-cur)>ans)
{
dfs(last,cur+1,len);//这样两个if就可以遍历所有情况
}
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>A[i];
}
A[0]=-(1<<30);//因为我们从第一个开始要对A[0]做一下处理,这是整数范围内最小滴负数啦
ans=0;
dfs(0,1,0);
cout<<ans<<endl;
return 0;}
这个难点依旧在结束条件和抽象的过程,我们为什么要一开始给(0,1,0),为什么要在if(cur==n+1)时return ,为什么要把A[0]赋成极小的负数,if(len+(n-cur)>ans)这个条件为什么要存在(这个我解释一下,第一如果这个没有我们发现就只能走第一个数字开头的情况,第二为什么给这个判断条件,因为只要你剩下的元素数列和你当前序列长度加起来比用于记录前一次最长子序列的长度的ans大,那就说明这一次搜查你有机会超过ans,否则哪怕接下来所有元素加进去你都不能比上一次搜索来的长,那说明你们注定是不可能的,那干嘛还费时费力呢?)
方法二,dp和它的跟班——(大声说出它的名字)
依旧正经分析一波(等我熟练使用CSDN下次一定给个表情包哈哈)
样例忘记给了,看我补一个:
输入:7
1 7 3 5 9 4 8
输出 :4
1.确定状态:定义F[i]表示以A[i]为结尾的最长不下降子序列长度(这个就是要记录和比较输出)(若为起点也可以的)max{F[I]}
2.确定状态转移方程和边界条件:设当前元素对应的序列的尾数也就是当前元素的前驱A[i],那么如果当前元素A[j]要加进这个序列,要满足:1<=i<=j-1&&A[i]<=A[j].然后每一次的问题本质都一样的,若符合,则F[j]=F[i]+1。同时有一个很容易忽略我也错过的点,每一个元素之前可能有多个子序列所以我们要取其中最大的一个,要加一个条件在循环的if里面
给代码
#include<bits/stdc++.h>
using namespace std;
const int MAXN=10005;
int A[MAXN],F[MAXN];//F[i]是指以A[I]结尾的子序列的长度
int n,ans;
int main()//其实就这就是尾递归,和循环的转化
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>A[i];
}
F[1]=1;//迭代初始化
ans=1;
for(int i=2;i<=n;i++)
{
F[i]=1;
for(int j=1;j<=i-1;j++)//j是i之前的元素下标
{
if(A[j]<=A[i]&&F[j]+1>F[i])//这个就是说的加的条件,体会一下//或者这样说,我们抽象点想第i个数比如现在j=i-n,满足F[j]+1>F[i],那么此时的F[i]=F[j]+1=p1,那么j继续++,之后凡是F[j]+<=p1的那就不用管了,只有大于时才更新
{
F[i]=F[j]+1;
}
}
if(F[i]>ans){ans=F[i];}
}
cout<<ans<<endl;
return 0;
}
好这道题over
最后一个,加法二叉树。(P1040 [NOIP2003 提高组] 加分二叉树)
题目描述
设一个n个节点的二叉树tree的中序遍历为(l,2,3,…,n),其中数字1,2,3,…,n为节点编号。每个节点都有一个分数(均为正整数),记第j个节点的分数为di,tree及它的每个子树都有一个加分,任一棵子树subtree(也包含tree本身)的加分计算方法如下:
subtree的左子树的加分× subtree的右子树的加分+subtree的根的分数
若某个子树为主,规定其加分为1,叶子的加分就是叶节点本身的分数。不考虑它的空
子树。
试求一棵符合中序遍历为(1,2,3,…,n)且加分最高的二叉树tree。要求输出;
(1)tree的最高加分
(2)tree的前序遍历
输入
第1行:一个整数n(n<=30),为节点个数。
第2行:n个用空格隔开的整数,为每个节点的分数(分数<=100)
输出描述 Output Description
第1行:一个整数,为最高加分(结果不会超过4,000,000,000)。
第2行:n个用空格隔开的整数,为该树的前序遍历。
输入
5
5 7 1 2 10
输出
145
3 1 2 4 5
中序遍历和前序遍历其实很清楚,推荐树的前序遍历、中序遍历、后序遍历详解 - 星朝 - 博客园
分析:
树的加分,是由“左子树的加分、右子树的加分、根的分数”这三部分来决定的,我们又可以按着左子树的左子树和右子树算,就这样一环套一环,是递归。然后可以用动态规划或者记忆化搜索来做。因为如果要求加分最大的话,必须要求它的儿子结点加分最大,所以就有了最优子阶段。我们可以枚举根来更新最大值。中序遍历又有个特点,在中序遍历这个序列上,某个点左边的序列一定是这个点的左子树,右边的序列,一定在这个点的右子树。
这就得出了一个数学式子(状态):
结束条件:到叶节点的时候就结束
搜索dfs:按照中序遍历的模式,把每一个节点都当根遍历一遍,比较出权值和最大的那个,然后在这种情况下按前序的方式输出节点
设一个区间l~r,表示这棵树是a[l]到a[r]的部分,(这里为了方便引用“yangrunze”的一部分题解)
- l<r时,1~r是指a[l]到a[r]的子树,加分为左子树的加分× 的右子树的加分+根的分数
- l==r时,l~r是指叶节点a[l](或a[r],反正l==r嘛),加分为a[l]的值
- l>r时,l~r为空节点,加分为1
long long dfs(int l,int r){ //dfs函数,数据比较大,开long long保险 if(l>r)return 1; //特殊情况1:如果为空节点,返回1 if(l==r)return a[l]; //特殊情况2:如果为叶节点,直接返回该节点的加分 long long maxn=0; //maxn来记录最大加分,作为最后的返回值 for(int i=l;i<=r;i++){ long long t=dfs(l,i-1)*dfs(i+1,r)+a[i];//t为以i为根节点的最大的加分 if(maxn<t) //更新最大值 maxn=t; } return maxn;//返回最大值 }
这就是具体实现,但又有重复,改成记忆化
long long dp[40][40]={0};//dp[l][r]记录l~r子树的最大加分 long long dfs(int l,int r){ if(l>r)return 1; if(l==r){ root[l][r]=l; return a[l]; } if(dp[l][r])return dp[l][r];//特殊情况3:如果以前已经算过了,那就直接返回以前存起来的结果 for(int i=l;i<=r;i++){ long long t=dfs(l,i-1)*dfs(i+1,r)+a[i]; if(dp[l][r]<t) dp[l][r]=t; } return dp[l][r];//返回最大值 }
最后贴代码
#include<iostream>
using namespace std;
int n,a[40],root[40][40];
long long dp[40][40]={0};
long long dfs(int l,int r)
{
if(l>r)return 1;
if(l==r)
{
root[l][r]=l;
return a[l];
}
if(dp[l][r])return dp[l][r];
for(int i=l;i<=r;i++){
long long t=dfs(l,i-1)*dfs(i+1,r)+a[i];
if(dp[l][r]<t){ //更新最大值以及根节点位置
dp[l][r]=t;
root[l][r]=i;
}
}
return dp[l][r];
}
void print(int l,int r)
{ //输出这棵树的先序遍历
if(l>r)return;
cout<<root[l][r]<<" ";
print(l,root[l][r]-1);
print(root[l][r]+1,r);
}
int main()
{
cin>>n;//输入
for(int i=1;i<=n;i++){
cin>>a[i];
dp[i][i]=a[i];
root[i][i]=i;
}
cout<<dfs(1,n)<<endl;//从头到尾搜索
print(1,n);
return 0; //树,是递归定义的
}
好啦,图书馆也快关门了;
ps:今天可是美育课摸鱼写代码的)
2021.11.30南大计科大一chy