题目大意
你有两棵树,它们的根节点都为1,且都有 n 个节点。你需要找到 {1,2,⋯,n} 的最大子集,使得:
1.在第一棵树上,集合中的节点是相连的,且对于集合中的任意两个节点 u,v,u,v 中的一个节点是另一个节点的祖先。
2.在第二棵树上,对于集合中的任意两个节点,u,v 中的任意一个节点都不是另一个节点的祖先。
请输出集合中元素的最大数量。
输入描述
第一行包含一个整数 t
(
1
≤
t
≤
10
)
(1 ≤ t ≤ 10)
(1≤t≤10)表示测试用例的数量。测试用例的描述如下:
每一组测试用例的第一行包含一个整数 n
(
1
≤
n
≤
3
×
1
0
5
)
(1 ≤ n ≤ 3×10^5)
(1≤n≤3×105)。
接下来 n-1 行每行包含两个整数
u
i
,
v
i
(
1
≤
u
i
,
v
i
≤
n
;
u
i
≠
v
i
)
u_i,v_i(1≤u_i,v_i≤n;u_i≠v_i)
ui,vi(1≤ui,vi≤n;ui=vi)表示第一棵树上的一条边。
再接下来 n-1 行每行包含两个整数
u
i
,
v
i
(
1
≤
u
i
,
v
i
≤
n
;
u
i
≠
v
i
)
u_i,v_i(1≤u_i,v_i≤n;u_i≠v_i)
ui,vi(1≤ui,vi≤n;ui=vi)表示第二棵树上的一条边。
保证给出的所有图都是树。
保证所有测试用例中 n 的总和不超过
3
×
1
0
5
3×10^5
3×105。
输出描述
对于每一组测试用例打印单个整数,表示最大子集中元素的数量。
样例输入
3
5
2 1
2 5
5 3
2 4
2 1
1 5
1 3
3 4
5
5 3
3 1
5 4
3 2
5 3
5 1
3 4
3 2
5
5 3
5 1
5 2
5 4
5 3
3 1
5 2
3 4
样例输出
3
1
2
思路
由题意可知,我们需要先根据第二棵树,得到判断两两节点是否构成祖孙的依据。直接用暴力的手段记录每个节点的祖先,时间复杂度达到了 O ( n 2 ) O(n^2) O(n2),一定会超时,故我们需要尽量去寻找时间复杂度为 O ( n ) O(n) O(n)的方法。
这就需要我们掌握一个知识点:树的DFS序。我们从根节点开始DFS整棵树,在进栈处和出栈处设置数组,分别记录每一个节点进栈和出栈的顺序。然后我们利用桶的思想,得到每一个节点进出栈的时间(即进栈时间表是线程的前半段,出栈时间表是线程的后半段)。
我们可以得到如下两个结论:
1.如果两个节点构成祖孙,那么这两个节点同在一条树链上,且祖先节点比子孙节点先入栈,子孙节点比祖先节点先出栈。
2.如果两个节点不构成祖孙,那么这两个节点不在一条树链上,且其中一个节点相对另外一个节点既先进栈,又先出栈。
我们根据以上两个结论查表,比较两个节点的进出栈时间,即可判断两个节点是否构成祖孙。建表的时间复杂度为 O ( n ) O(n) O(n)。
然后我们在第一棵树上进行搜索。由于要构造的最大子集中的节点在第一棵树上是相连的,还要满足在第二棵树上两两不构成祖孙的条件,并且最后要求的是子集可以容纳节点个数的最大值,容易想到利用树上滑动窗口维护这一子集。
由树的DFS序,每次搜索会先彻底搜索完整条树链,再向上回溯。那么滑动窗口则在整条树链上进行滑动,以满足在第一棵树上两两构成祖先的关系。用传统的双指针模拟窗口,在树上则显得不易操作。因此我们用双向队列来维护滑动窗口。在每次向滑动窗口中加入节点前,我们先将右指针指向队首,如果指向的节点与要新加的节点不构成祖孙,则右指针向左滑动,直到不满足条件为止。然后我们加入新节点,并更新答案,即滑动窗口当前的长度。
至于左指针,就是右指针向左移动到达的极限位置。在正向搜索完整条树链后,我们利用树的DFS序,将队列中的节点从队尾弹出,队首树链的公共部分保留,并接着搜索该公共部分的其它树链分支。
我们用静态邻接表vector来保存树的边集。不要忘了将给出的有向边转换成无向边。具体的实现方式见代码。
考点
树的DFS序
树上滑动窗口的维护
AC代码(含注释)
#include<bits/stdc++.h>
using namespace std;
const int maxn=3e5+5;
vector<int> e1[maxn],e2[maxn];//树1,树2
int n,u,v,ans;
int inorder[maxn],outorder[maxn];//进栈序,出栈序
int incnt,outcnt;
int innode[maxn],outnode[maxn];//进栈节点,出栈节点
deque<int> q;//双向队列维护滑动窗口
void iodfs(int cur,int fa){//记录树2的DFS序
innode[incnt++]=cur;//记录进栈节点
for(auto nxt:e2[cur]){//访问当前节点的邻接节点
if(nxt!=fa)iodfs(nxt,cur);//如果邻接节点不是父节点,继续DFS
}
outnode[outcnt++]=cur;//记录出栈节点
}
void get_ancestor(){//建立进出栈时间表
iodfs(1,0);
for(int i=0;i<=n-1;i++){//利用桶的思想统计进出栈的顺序
inorder[innode[i]]=outorder[outnode[i]]=i;
}
}
bool is_ancestor(int u,int v){//根据进出栈时间表判断两节点是否构成祖先
if(inorder[u]<inorder[v]&&outorder[u]>outorder[v])return true;
if(inorder[u]>inorder[v]&&outorder[u]<outorder[v])return true;
if(inorder[u]<inorder[v]&&outorder[u]<outorder[v])return false;
if(inorder[u]>inorder[v]&&outorder[u]>outorder[v])return false;
}
void dfs(int cur,int fa,int left){//DFS树1
q.push_back(cur);//将当前节点加入队尾
ans=max<int>(ans,q.size()-left);//更新答案
for(auto nxt:e1[cur]){//访问当前节点的邻接节点
if(nxt!=fa){//如果邻接节点不是父节点
int right=q.size();//初始化右指针
while(right>left){//尝试向左移动
if(is_ancestor(q[right-1],nxt)==false)right--;//树2中不是祖先,就左移扩大滑动窗口长度
else break;//否则终止
}
dfs(nxt,cur,right);//右指针左移到极限成为左指针
}
}
q.pop_back();//回溯时从队尾弹出路径上的节点
}
int main(){
int t;
cin>>t;
while(t--){
cin>>n;
for(int i=1;i<=n;i++){//释放容器的内存空间
e1[i].clear();
e2[i].clear();
}
for(int i=1;i<=n-1;i++){
cin>>u>>v;
e1[u].push_back(v);//有向边转无向边
e1[v].push_back(u);
}
for(int i=1;i<=n-1;i++){
cin>>u>>v;
e2[u].push_back(v);//有向边转无向边
e2[v].push_back(u);
}
q.clear();//释放队列的内存空间
incnt=0,outcnt=0,ans=0;//初始化
get_ancestor();//根据树2建表
dfs(1,0,0);//根据树1搜索
cout<<ans<<endl;
}
return 0;
}
心得
这道题目考察了树上的区间信息维护,虽然还有线段树和主席树等做法,但难度过大,不易理解。这里给出的方法是容易理解的,也是相当巧妙的。本题在实际比赛时拉开的差距非常大,做出本题后排名从一千上升至一百的队伍大有人在。大概算是区域赛银牌难度。值得仔细思考。