1617. 统计子树中城市之间最大距离
图论啊,那我先寄为敬。今天是代码搬运工。
题意很好理解啊,就是给你一个图,让你返回所有直径对应的子树的数量。是的没错,是所有直径,你不仅要枚举 1 − d 1-d 1−d 的所有直径,你还要枚举这些直径对应的所有子树。很好理解但一点思路都没有,逛了各个大神们题解,介绍三个比较好理解的方法。
既然又要枚举直径又要枚举子树。那要么是直接枚举直径,对于每个直径找对应的子树的数量,但是太难了,官解第三个方法就是这个,看不懂,放弃了。那就只能枚举子树,计算其直径,然后统计了。前两个方法就是依据这个来的。
其实说子树就复杂了,题目给的虽然是树,但是实际上还是边的集合,而子树不就是这个集合的子集吗。说到枚举子集,那必然是状态压缩了(即用二进制来表示一个子集,对于长度为 n n n 的集合,可以用十进制数的 0 − 2 n 0-2^n 0−2n 的二进制来表示一个子集,二进制对应的位为 1 1 1 表示这个元素在这个子集中,反之则表示不在)。那枚举子集的问题解决了,怎么计算一棵树的直径呢?我们这里直接说定理啊,不难理解:
- 找到距离根节点最远和次远的点,这两个点到根节点的距离之和就是该树的直径。
- 先找到一个距离根节点最远的点。再以这个点为根节点找一个最远的点,这两个点之间的距离即为树的直径。
三个方法中,状态压缩是必要前提,根据直径的计算方法不同,就有了前两个方法。
树形动态规划
根据第一个求树的直径的方法,我们每次以当前节点为根向下延伸求一个最远距离 f i r s t first first 和一个次远距离 s e c o n d second second,记录 f i r s t + s e c o n d first+second first+second 的最大值,遍历完子树之后即可得到树的直径。更多细节看代码注释。
class Solution {
public:
vector<int> countSubgraphsForEachDiameter(int n, vector<vector<int>>& edges) {
vector<vector<int>> adj(n);
for(auto edge:edges){
//创建邻接表
int x=edge[0]-1;
int y=edge[1]-1;
adj[x].push_back(y);
adj[y].push_back(x);
}
function<int(int, int& ,int& )> dfs=[&](int root,int& mask,int& d){
int first=0;
int second=0;
mask&=~(1<<root);//在全集中去掉根节点
for(int v:adj[root]){
//枚举所有与根节点直接相连的点
if(mask&(1<<v)){
//如果这个点在子树里,才进行下一步递归
mask&=~(1<<v);//从子树中删去这个点,递归这个点的孩子
int dis=1+dfs(v,mask,d);
//这一步已经递归完了返回结果了,我们得到了一个距离,现在根据这个距离更新最远距离和次远距离
if(dis>first){
//这个距离比最远距离还要大,二者都需要更新
second=first;
first=dis;
}
else if(dis>second){
//这个距离大于次远距离小于最远距离,只更新次远距离
second=dis;
}
}
}
d=max(d,first+second);//更新当前最大直径
//对于每一个子函数,返回最远距离,再由父函数+1,最后回到根节点就可以得到最远距离,次远距离只是顺路得到的
return first;
};
vector<int> res(n-1);
for(int i=1;i<(1<<n);i++){
//状态压缩枚举子树
//已知子树是一个连通图,那么,把任何一个节点当作根,都可以得到一棵树
//我们知道,i的二进制表示了全集中每个元素是否在子集中,这一步其实就是找出子集中编号最大的节点作为该子树的根节点
//这个函数的作用是得到一个十进制数的二进制前导0的个数,int类型四字节,32位
int root=32-__builtin_clz(i)-1;
int mask=i;
int d=0;
dfs(root,mask,d);
if(mask==0&&d>0){
//如果遍历过程需要不断移除当前节点,所以如果mask!=0,意味着有节点不可达,是非法子树。
//d>0是为了处理res边界问题,且d=0时说明这个点时孤立的,也没有意义
res[d-1]++;
}
}
return res;
}
};
BFS+DFS
根据第二个方法,我们需要将每一个子图搜两次。第一次从根节点开始,找一个最远距离,即图的深度;第二次将这个最远的点作为根节点,再求一次深度,第二次得到的深度即为树的直径。这里有一个小技巧:如果我们在搜第一遍的时候用BFS,因为BFS的算法特性,从根节点开始走,最后走到的点一定是最远的,那我们可以在判断子图的连通性的同时得到最远的点,即第一个深度。更多代码细节就不赘述了,看一看方法一的代码注释。
class Solution {
public:
vector<int> countSubgraphsForEachDiameter(int n, vector<vector<int>>& edges) {
vector<vector<int>> adj(n);
for(auto edge:edges){
int x=edge[0]-1;
int y=edge[1]-1;
adj[x].push_back(y);
adj[y].push_back(x);
}
function<int(int, int ,int )> dfs=[&](int parent,int u,int mask){
int depth=0;
for(int v:adj[u]){
if(v!=parent&&mask&(1<<v)){
//判断是不是父节点,保证遍历的都是子节点,不会开倒车
depth=max(depth,1+dfs(u,v,mask));//每次递归的都是同一棵子树,所以不用删去节点
}
}
return depth;
};
vector<int> res(n-1);
for(int i=1;i<(1<<n);i++){
int x=32-__builtin_clz(i)-1;
int mask=i;
int y=-1;//第一次的最远点
queue<int> q;
q.push(x);
mask&=~(1<<x);
while(!q.empty()){
y=q.front();
q.pop();
for(int v:adj[y]){
if(mask&(1<<v)){
mask&=~(1<<v);
q.push(v);
}
}
}
if(mask==0){
//合法子树
int d=dfs(-1,y,i);
if(d>0){
res[d-1]++;
}
}
}
return res;
}
};
最短路径+动态规划
这个做法真的是另辟蹊径啊,太妙了。这个做法是构建新的子树,有点类似于官解第三个方法。这个做法基于题设的一个前提:保证了给的是一棵树。换句话说,这个拓扑里面不可能有环。进一步想,对于一棵子树,如果我们往这个子树里面加一个与子树里面的某一节点有邻接边的节点,即新加入的这个点在加入子图后是可达的。再结合题设,就可以推断出,新加入的这个点一定是一个叶子节点。可以反证,如果这个点不是叶子节点,他有两个邻接边,那就只有两个情况:第一,新加入这个节点导致出现了环,这与题设相悖;第二,之前的子树是不合法子树,有节点不可达。第一种情况是不允许出现的,而第二种情况如下图左所示:一个新节点的加入联通了子树和一个不可达的节点。这种情况我们可以直接把它排除掉,因为这棵子树可以通过右边这种情况实现,不会存在漏答案。
既然新加入的点一定是一个叶子节点,那就好说了。因为直径可以描述为,一个叶子节点到所有叶子节点的最大值。因此我们枚举合法的子树,试图往里加一个新的节点,并计算这棵新树直径,一样可以得到所有子树的直径,最后统一以下即可得到最终答案。
class Solution {
public:
vector<int> countSubgraphsForEachDiameter(int n, vector<vector<int>>& edges) {
vector<vector<int>> dis(n,vector<int>(n,INT_MAX));//初始化个点之间的距离为节点数目,即可能距离的最大值,方便后面更新最短距离
vector<int> dp(1<<n,0);//子树的直径
for(int i=0;i<n;i++) dis[i][i]=0;//初始化同一节点之间距离为0
for(auto edge:edges){
//相邻节点间距离为1
int x=edge[0]-1;
int y=edge[1]-1;
dis[x][y]=1;
dis[y][x]=1;
dp[(1<<x)+(1<<y)]=1;//所有由两个节点组成的子树,直径都为1
//弗洛伊夫算最短路径
for(int k=0;k<n;k++){
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
if(dis[i][k]!=INT_MAX&&dis[k][j]!=INT_MAX){
dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
}
}
}
}
for(int j=1;j<(1<<n);j++){
if(dp[j]==0) continue;//该子树本身不合法,直接跳过
for(int i=0;i<n;i++){
//遍历节点找符合条件的新节点
//当前节点已经在子树中了或者该子树已经计算过了,跳过。如101+010=011+100=111,是同一棵子树,只是节点添加顺序不同
if(((1<<i)&j)!=0||dp[j+(1<<i)!=0]){
continue;
}
for(int k=0;k<n;k++){
//找一个与需要加入的节点直连的点,即新节点加入子树的连接点
if(((1<<k)&j)!=0&&dis[i][k]==1){
//这个点在子树集合里,且与需要新加入的点之间有边
dp[j+(1<<i)]=dp[j];//新子树的直径不会小于旧子树的直径,先赋个值
break;
}
}
//如果这个新节点没找到符合条件的节点来加入旧子树,即dp[i+(1<<j)]没被赋值,直接跳过
if(dp[j+(1<<i)]==0) continue;
for(int k=0;k<n;k++){
//寻找这个新节点与旧子树中所有节点距离的最大值,即为新子树的直径
if(((1<<k)&j)!=0){
dp[j+(1<<i)]=max(dp[j+(1<<i)],dis[i][k]);
}
}
}
}
}
vector<int> res(n-1,0);
for(int j=0;j<(1<<n);j++){
if(dp[j]!=0){
res[dp[j]-1]++;
// cout<<dp[j]<<endl;
}
}
return res;
}
};