之所以这样命名树规,是因为树形DP的这一特殊性:没有环,dfs是不会重复,而且具有明显而又严格的层数关系。利用这一特性,我们可以很清晰地根据题目写出一个在树(型结构)上的记忆化搜索的程序。而深搜的特点,就是“不撞南墙不回头”。这一点在之后的文章中会详细的介绍。
首先是扫盲,介绍几条名词的专业解释以显示我的高端(大部分人可以略过,因为学习到树规的人一下应该都懂……):废话说完了,下面是正文:
拿到一道树规题,我们有以下3个步骤需要执行:
判断是否是一道树规题:
即判断数据结构是否是一棵树,然后是否符合动态规划的要求。如果是,那么执行以下步骤,如果不是,那么换台。
建树:通过数据量和题目要求,选择合适的树的存储方式。
如果节点数小于5000,那么我们可以用邻接矩阵存储,如果更大可以用邻接表来存储(注意边要开到2*n,因为是双向的。这是血与泪的教训)。如果是二叉树或者是需要多叉转二叉,那么我们可以用两个一维数组brother[],child[]来存储(这一点下面会仔细数的)。
写出树规方程:通过观察孩子和父亲之间的关系建立方程。我们通常认为,树形DP的写法有两种:6、软件安装:判断环+缩点+多叉转二叉;
【4、5、6属于依赖问题的变形】
基本的知识掌握和步骤了,我们就通过习题来感受一下树规的魅力,先来看这样一道题:第2行:n个用空格隔开的整数,为每个节点的分数(分数<100)。
【输出格式】
第1行:一个整数,为最高加分(结果不会超过4,000,000,000)。看到这个问题,我们首先应该想到的是这道题是否属于动态规划,而这里我们发现,结合问题,如果整棵树的权值最大,必然有左子树的权值最大,右子树的权值也最大,符合最优性原理。所以是动态规划。
而却不是一道树规的题目。因为我们可以用区间动规的模型解决掉:直接定义一个f[i][j]表示从i到j的最大值,则f[i][j]=max(f[i][k-1]*f[k+1][j]+a[k]),枚举k即可。接下来是如何建树的问题,只有把树建好了,才能输出其前序遍历。于是,我们看到了两个关键词:二叉树,中序遍历。有了这两个关键词,加上区间动规,这棵树就能建起来了。根据二叉树的特性来建树(这里不再具体讨论树的详细的构造了,中序遍历和前序遍历不懂得自己百度)。所以这颗树的前序遍历,只需要边动规边记录下root[i][j]=k表示i到j的根为k即可确定树的构造。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
【小结】:拿到一道题目,首先我们要做的是看清题目,判断这是一道考察什么算法的题目。只有建立在正确思路基础下的算法,才是有意义的,正确的算法,也是事半功倍的算法。而此题是批着 树形 外观的 非树形动态规划题。而真正的树形动态规划是在树上做动态规划。
再执行第二步:我们观察到题目数据量不大,所以有两种选择:邻接矩阵和邻接表。因为邻接矩阵的代码简单,思路清晰,所以建议能写邻接矩阵的时候就不要写邻接表了。我们设ma[x][y]为边的值,因为树是双向的,所以要再记录ma[y][x]。
设tree[v,1]为节点v的左子树,tree[v,2]为节点v的右子树,然后我们再递归建树(因为树是递归定义的,所以很多时候建树都要考虑递归)。
建树的问题解决的了,我们就要列状态转移方程了。根据求什么设什么的原则,我们定义f[i][j]表示以i为节点的根保留k条边的最大值,那么f[v][k]=max(f[v][k],(f[tree[v][1]][i]+f[tree[v][2]][k-i-1]+num[v])),我们枚举i就可以了。正如我开头提到的。因为树是递归定义的所以我们可以用记忆化搜索的形式(dfs)来具体实现。而树本身严格分层,而且没有环。所以是不会重复的。
F[1][Q+1]就是答案。因为题目中给的是边的权值,而我们在处理时将每条边的权值全赋给其所连的父节点和子节点中的子节点(将关于边的问题转化为关于点的问题),所以最后是Q+1,表示点的数目。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
【小结】:在树的存储结构上,我们一般选的都是二叉树,因为二叉树可以用静态数组来存储,并且状态转移也很好写(根节点只和左子节点和右子节点有关系)。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
【小结】:无论是多叉树还是二叉树,只要我们把树以正确的形式建立起来,那么我们再根据建树的形式和题目要求,找出孩子和父亲之间的关系,那么状态转移方程很容易就求解出来了。多叉其实也不是很难。对么?呵呵,那么再看下面一道题:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
【小结】:当题目中的数据结构是多叉树的时候,我们有两种选择:直接在多叉树上动规,或者转化为二叉树后动规。毫无疑问,二叉树上的动规是简洁的。但是,并不是说所有的多叉树都需要转化,一般情况下,当根节点与孩子节点有着必然的关系时才会转化。这需要我们多做题目,增加对树规的感觉才能游刃有余。
分两种情况:取(1)和不取(0)。
然后,我们继续利用已经求得的f[i][j]的值来思考如何找到路径:
首先定义一个path()函数。如果f[i][j]=f[b[i]][j],那么节点i必然没有取,让ans[i]=0;否则,节点i一定取到了。(为什么呢?其实,这是依照第一问的dfs来思考的,第一问的dfs是这样定义的,所以我们就这样考虑了。)然后依照上一问,if(f[x][y]==f[b[x]][k-1]+f[c[x]][y-k]+s[x]),那么我们在i节点后选的一定是以上的方案,在这时让ans[i]=1,继续深搜path()即可。最后从1到n依次输出取到的点即可。
#include
#include
#include
#include
#include
#include
#include
#include
#include
【小结】:路径输出的问题,在不同的题目中有不同的解法,比如说边求值边记录等等,而在树规中,利用动规和树的特点,求解完后再原路返回找一遍,是一种比较容易想到且容易操作的性价比比较高的算法。
输出:
5
【算法&思路】:同样,这道题目类似与第4题,是一个依赖的问题,毫无疑问是一道动态规划,但是它确实是树规么?我们来想这样一组数据,1依赖2,2依赖3,3依赖1。这样符合题目要求,但有形成了环,所以不是一棵树了。但是根据题目,这样特殊的情况,要么全要,要么全就不要。所以,事实上我们可以将这个环看成一个点再来动规,即缩点。如何判断是否是一个环呢,依照数据范围,我们想到了floyed(弗洛里德),这是在这种数据范围内性价比最高的方式。最后树规。于是一个比较清晰的步骤就出来了:判环,缩点,树规。
接下来是细节:首先存树,毫无疑问,是邻接矩阵。
做floyed。如果两点之间mapp[i][j]中有另一条路径相连,即mapp[i][k]=1 && mapp[k][j]=1(1表示两点是通的);那么mapp[i][j]也是通的且是环。
缩点。这个是最麻烦的,麻烦在于我们要把缩的点当成一个新点来判断,而且要判断某个点是否在某个环里。我们用染色法来判断,用所占的空间w控制颜色的对应,有以下三种情况:
1、点i所在的环之前没有判断过,是新环。那么,我们将这个新环放到数组最后,即新加一个点,然后让这两个点的空间标记为负值tmpw,且tmpw+tmpn(新点的下标)等于原来的点数,这样,我们就可以通过某个点的空间迅速找到他所在的新点。像钥匙一样一一对应;
2、点i所在的环之前已经判断过了,是旧环(已合成新点),且i是环的一部分。那么我们就把i也加到这个新点里面,即体积,价值相加即可;
3、点j所在的环是旧环,但是i不是环的一部分(例如1依赖2,2依赖3,3依赖1。4也依赖1,那么,4所在的是个环,但4不属于环的一部分)。那么,把j的父亲转到新点上d[j]= n-w[d[j]]。
以上缩点的工作做完之后,剩下的就是一棵树。就可以在这上面动规了:先将其转换成一棵左孩子右兄弟的二叉树,之后记忆化。i的孩子不取f[b[x]][k]=dfs(b[x],k);还是取:
f[c[x]][y-i]=dfs(c[x],y-i);
f[b[x]][i]=dfs(b[x],i);
f[x][k]=max(f[x][k],v[x]+f[c[x]][y-i]+f[b[x]][i]);
最后答案是f[c[0]][m]。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
【小结】:依赖问题的变化很多,比如基本树规,记录路径,有环等等。但是他们都有一些共同的特点,比如说记忆化的方程差不多。根据题目,我们应该会判断给的数据是否是图,是树,能想出特殊情况。树毕竟是图的一种特殊形式,而二叉树又是树的一种特殊形式。如果能将一个问题由复杂向简单转换,那么我们不仅思路会清晰很多,代码量也会少很多。下面一道题目,就是我们所说的很少见的根节点向叶子节点动规的问题。
问题描述
有一棵 n 个节点的树,树上每个节点都有一个正整数权值。如果一个点被选择了,那么在树上和它相邻的点都不能被选择。求选出的点的权值和最大是多少?
输入格式
第一行包含一个整数 n 。
接下来的一行包含 n 个正整数,第 i 个正整数代表点 i 的权值。
接下来一共 n-1 行,每行描述树上的一条边。
输出格式
输出一个整数,代表选出的点的权值和的最大值。
样例输入
5
1 2 3 4 5
1 2
1 3
2 4
2 5
样例输出
12
样例说明
选择3、4、5号点,权值和为 3+4+5 = 12 。
数据规模与约定
对于20%的数据, n <= 20。
对于50%的数据, n <= 1000。
对于100%的数据, n <= 100000。
权值均为不超过1000的正整数。
解题过程
刚学习完树形动态规划的原理,所以乍一看就知道此题应该用树形动态规划解决。分两步:1、建树。2、动态规划。
刚开始选择的存储结构是二维数组,既每一行表示树的一层,每一列表示该层(行)的所有节点;记录下树的最大层数,从最后一层开始改变每个节点的状态,最后从根节点中获取最优解。
#include
#include
#include
#define M 100010 //数组最大长度
int fu[M],hz[M][M],shu[M][M],pow[M],f[M][2];
//父节点数组; 孩子数组hz[i][0]第i个节点的孩子数,hz[i][j](j>0)表示i节点的第j个孩子
//树二维数组,shu[i][0]表示第i层节点数,shu[i][j](j>0)表示第i层的第j个节点;
//pow[]权值数组,p[i]表示第i个节点的权值
//f[i][1]保留节点i时最大权值,f[i][0]不保留节点i时的最大权值
int main()
{
int n,i,j,u,v;
memset(fu,0,sizeof(fu));
memset(hz,0,sizeof(hz));
memset(shu,0,sizeof(shu));
memset(f,0,sizeof(f));
scanf("%d",&n);
for(i=1;i<=n;i++)scanf("%d",&pow[i]);
for(i=1;imaxlev)maxlev=s;
}
//动态规划
int now,k,a,b;
for(i=maxlev;i>0;i--)
{
for(j=1;j<=shu[i][0];j++)
{
now=shu[i][j];
if(hz[now][0]==0)
{
f[now][0]=0;
f[now][1]=pow[now];
}
else
{
for(k=1;k<=hz[now][0];k++)
{
a=f[hz[now][k]][0];
b=f[hz[now][k]][1];
f[now][1]+=a;
if(b>a)a=b;
f[now][0]+=a;
}
}
}
}
int sum=0;
for(i=1;i<=shu[1][0];i++)
{
now=shu[1][i];
a=f[now][0];b=f[now][1];
if(b>a)a=b;
sum+=a;
}
printf("%d\n",sum);
return 0;
}
按理说这个算法是可行的,但是再提交答案时,居然发生运行错误,我看了看内存使用率非常大,返回题目看了数据规模,节点数n<=100000,也就意味着要用二维数组存储树的话,二维数组至少定义为shu[100000][100000],占用了非常大的控件资源。再者,题目给n个顶点,n-1条边,也就意味着树没有孤立点,并且有且仅有一个根节点,可见每一层的节点很多时候是远少于100000的,所以应该改用动态存储结构。#define MAX_TREE_SIZE 100
typedef struct PTNode{//节点结构
TElemType data;
int parent;//双亲位置
}PTNode;
typedef struct{ //树结构
PTNode nodes[MAX_TREE_SIZE];
int r,n; //根节点位置和节点数
}PTree;
这种存储结构利用了每个节点(除根节点以外)只有唯一双亲的性质。PARENT(T,x)操作可以在常数时间内实现。反复调用PARENT操作,直到遇见无双亲的节点时,便找到了树的根,这个就是ROOT(x)的过程。但是,在这种表示法中,求节点的孩子时需要遍历整个结构。
《2》、孩子表示法
这里主要给出一种类似于邻接表的表示法。把每个节点的孩子节点排列起来,看成是一个线性表,且以单链表作为存储结构,则n个节点有n个孩子链表(叶子节点的孩子链表为空表)。而n个头指针又组成一个线性表,为了便于查找,可采用顺序存储结构。这种存储结构可形式地说明如下:
#define MAX_TREE_SIZE 100
typedef struct CTNode{ //孩子节点
int child;
struct CTNode *next;
}*ChildPtr;
typedef struct{
TElemType data;
ChildPtr firstchild; //孩子链表头指针
}CTBox;
typedef struct{
CTBox nodes[ MAX_TREE_SIZE ];
int n,r; //节点数和根节点位置
}CTree;
与双亲表示法相反,孩子表示法便于那些涉及孩子操作的实现,却不适合用于PARENT(T,x)的操作。我们可以把双亲表示法和孩子表示法合起来,既将双亲表示和孩子链表和在一起。
#define MAX_TREE_SIZE 100
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild,*nextsibling;
}CSNode,*CSTree;
#include
#include
#include
#include
#define M 100100 //最大长度
using namespace std;
//孩子节点结构
typedef struct Node
{
int vex;
Node* next;
}Child;
Child* head[M];//链表头数组
int f[M][2],pow[M],visit[M];
//pow[]权值数组,p[i]表示第i个节点的权值
//f[i][1]保留节点i时最大权值,f[i][0]不保留节点i时的最大权值
//visit[i]==1表示i点被访问过,visit[i]==0表示节点i未被访问过
//添加边(对称的)
void addADJ(int u,int v)
{
Child *p,*q;
p=(Child*)malloc(sizeof(Child));
p->vex=v;
p->next=head[u];
head[u]=p;
q=(Child*)malloc(sizeof(Child));
q->vex=u;
q->next=head[v];
head[v]=q;
}
//动态规划获取结果
void GetResul(int v)
{
visit[v]=1;
Child *p;
for(p=head[v];p!=NULL;p=p->next)
{
if(visit[p->vex]==0)
{
GetResul(p->vex);
f[v][1] = f[v][1]+f[p->vex][0];
f[v][0]+=max(f[p->vex][0],f[p->vex][1]);
}
}
f[v][1]+=pow[v];
}
int main()
{
int i,j,u,v,n;
memset(head,NULL,sizeof(head));
memset(f,0,sizeof(f));
memset(visit,0,sizeof(visit));
scanf("%d",&n);
for(i=1;i<=n;i++)
{
scanf("%d",&pow[i]);
}
for(i=1;i