本文仅为个人学习笔记,请谨慎参考,如有错误欢迎批评指正。
第一篇文章主要看树的重心的部分
第二篇文章才是和本题完全一致
https://blog.csdn.net/a_forever_dream/article/details/81778649
https://blog.csdn.net/jackypigpig/article/details/69808594
(1)用伪代码描述求树重心的算法。
(2)将下面的树作为输入时,写出求解上述问题的求解过程以及求解结果。要求写出求解过程中主要变量的变化过程。
(3)编写程序求解该问题,并分析算法的时间复杂度。
【分析】
树的重心,也叫树的质心。即树的一个结点,把它删掉后的所有子树的最大结点数(相比于删掉其他结点)最小。如图所示:
要求树的重心,也许第一想法是遍历一次所有结点,把每个结点都当成重心,计算每棵子树的结点数,再来进行比较。但这种暴力法是不可取的。它会重复计算很多次相同的路径,我们可以仅扫描一次整棵树就能得到以每个点作为重心的最大子树结点数。这里要用到树的点分治法。分治法,我把它理解成后序遍历的递归调用,但是递归也只能以一个结点为起点进行递归呀,怎么能算出以所有点为起点的最大子树结点数呢?这里我们还是以上面的树为例子。用n表示整棵树的结点数,这里n=5;用size[i]代表以i为根的树的结点数;用max_child[i]代表以i为根的最大子树结点数;用min来更新max_child[i]中的最小值,它用来更新重心,min起始值是很大的数。
按照后续遍历的递归,我们首先能算出点4的size[4]=1,红色部分是n-size[4]=4个结点。之所以能这么分,是因为点4只会有一个父结点,所以红色部分始终能作为它的一棵子树。比较size[4]和n-size[4]的大小,以点4为根的最大子树结点数max_child[4]=4,min更新为max_child[4]的4。
然后是算出点5的size[5]=1,也是类似点4。红色部分是n-size[5]=4个结点,以点5为根的最大子树结点数max_child[5]=4,min不变。
接着是算出点2,也就是它自身的1个结点,加上它的子树点4和点5的size。绿色部分为它的子树,分别为size[4]=1个结点、size[5]=1个结点,红色部分是n-size[2]=2个结点,所以以点2为根的最大子树结点数max_child[2]=2,min更新为2。
接着是算出点3,也就是它自身的1个结点。红色部分是n-size[3]=4个结点,所以以点3为根的最大子树结点数max_child[3]=4,min不变。
接着是算出点1,也就是它自身的1个结点,加上它的子树点2和点3的size。绿色部分为两棵子树,size[2]=3个结点、size[3]=1个结点。红色部分已经没有了,n-size[1]=0个结点。所以以点1为根的最大子树结点数max_child[1]=3,min不变。
min一旦更新,重心root也会更新为对应的结点。只是这里没写出来,看代码就知道了。
所以说,这个递归的开始点选谁不重要,选择任意点都能算出以每个点为根的最大子树结点数,进而求出重心。
求树的重心的代码如下:
// 全局变量
int n=17; // 所有结点数
int size[n];// 以n为根的树的结点数
int max_child[n];// 以n为根的树的最大子树的结点数
int min;// max_child中最小的那个
int first[n+1],edge[(n-1)*2]// 顶点表,边表
int gravity=0;// 被选为重心的结点
// 传入参数
// start 代表当前结点
// parent 代表当前结点的父结点,这里是为了防止遍历start的子结点的时候把父结点也遍历进去了
void getGravity(int start, int parent)
{
// size[start]代表当前结点的个数,初始为1是算上本身
size[start]=1;
// max_child[start]代表当前结点的最大子树结点数
max_child[start]=0;
// 遍历以start结点为起点的所有边(除去连接父结点的边)
for(int i=first[start]; i; i=edge[i].next)
{
// end为当前边的终点(也是start点的子结点)
int end=edge[i].end;
// 如果这个终点已经被遍历或者这个终点是父结点,就跳过
if(visited[end] || end==parent){
continue;
}
// 继续遍历终点的子结点,这里其实就是遍历start的一棵子树的所有结点数
getGravity(end, start);
// 遍历完这棵子树的所有结点后,把子树的结点数加起来
size[start]+=size[end];
// 如果这颗子树的结点数大于max_child,就更新它
if(size[end] > max_child[start]){
max_child[start]=size[end];
}
}
// 上面的循环是用来遍历start的每一棵子树并比较出最大子树结点
// 接下来就是算“红色部分”,也就是n-size[i]部分的结点数,并比较出最终的最大子树结点
if(n-size[start] > max_child[start]){
max_child[start]=n-size[start];
}
// 从max_child中比较出最小的,以找出重心
if(min < max_child[start]){
min=max_child[x];
gravity=start;
}
}
这里对树的存储方法是,用顶点表first来存每个点,first[i]代表编号为i的结点的对应的第一条边的编号。边表edge的每一行代表一条边,包含这条边的起点、终点、权重和下一条相同起点的边。因为这些边是无向的,所以一条边在边表里存了两次。
添加树结点和边的代码如下:
int first[n+1],edge[(n-1)*2]// 顶点表,边表
int num=0;
void addNodeAndEdge(int start,int end,int weight)
{
num++;// 编号,没错,要从1开始
edge[num].start=start;// 起点
edge[num].end=end;// 终点
edge[num].weight=weight;// 权重
edge[num].next=first[start];// 下一条相同起点的边
first[start]=num;// 加入顶点
}
以上仅仅是对求树重心的分析。找出重心以后,接下来就是要找距离小于K的点对和路径。
在选出树的重心之后,我们计算所有点到这个重心的距离(即权重),也是用递归的办法。这个应该很好理解,不做过多解释。
int t=0;
// start是传入的点,parent是start的父结点,weight是start和parent连线的权重
void getDistance(int start, int parent, int weight)
{
// dis数组保存了每个点到重心的距离(权重),为什么用t来做下标而不是点的编号呢
// 因为后面的做法只用数点对的个数,不在乎是谁到谁
// t是从1开始的
dis[++t]=weight;
for(int i=first[start]; i; i=edge[i].next)
{
int end=edge[i].end;
// 如果这个终点已经被遍历或者这个终点是父结点,就跳过
// 这点很重要,因为如果传入的start不是根结点而是根结点的子树的时候
// 它就不会把根结点再遍历一次
if(visited[end] || end==parent){
continue;
}
getDistance(end, start, weight+edge[i].weight);
}
}
这一步我们拿到了dis数组,每个点到重心的距离都保存在dis里面了。再次强调,dis的下标和结点编号没关系。还是以前面的例子,如下图所示:
接下来要做的是求长度小于等于K的路径数。我的第一想法是把dis中小于等于K的点找到,这样我们就选出点到重心距离小于等于K的点对。但是!根据第二篇参考文章,并不是这样做的。引用部分第二篇参考文章:
- 之后,这棵子树中相连的路径会经过重心且对答案有贡献的点对(
i,j
) (i
)就会是这样: dis[i]+dis[j] <= K
且在去除重心后,i 与 j 不在同一个联通块里。
- 不过显然要满足“不在同一个联通块里”这个条件有点突兀,于是就有了一个小技巧:
- 先不管在不在一个联通块这个条件,算出当前这棵树的符合路径数,之后再将得出的个数减去 以重心的儿子节点为根的子树内 的 点对 路径距离(经过重心)小于等于K的个数,就行了。
它的意思就是我们现在有了每个点到重心的距离之后,我们把dis按照从小到大排序(排序是为了好算出比K小的点对),进行两两相加(相加的dis是绝对要经过重心的),比如:
2——1——3,对应dis[2]和dis[5]
4——2——1——3,对应dis[3]和dis[5]
但是有一种情况是不妙的,比如4——2——1——2,对应dis[3]和dis[2],点4和点2他们之间本来是不用经过重心点1,这怎么排除掉呢?
第二篇文章说,我们把所有组合的点对的dis相加,计算出小于等于K的点对的数量后,再减去重心点1下的所有子树(以点2和点3为根的树)的经过重心的点对的数量。再对所有子树递归,子树找出重心,算出dis,两两组合,算出点对数量,再减去它的子树的经过重心的点对数量......一直这样下去,直到叶子结点。这样就把“情况不妙”的点排除了。
我不懂为什么一定是算经过重心的点对的数量,而不计算以重心为起点、其他点为终点的点对的数量。时间紧迫,看到好几篇同类文章也是这种思路,就默认这种方法来理解代码了。
// 传入重心,以及重心和它的父结点之间的权重
int work(int start,int weight) {
t=0;
// 算出start到各个结点的距离
getDistance(start, 0, weight);
// 得到dis数组后,对其进行从小到大排序
// 注意,这里的t已经不是0了,它是全局变量,在getDistance里面遍历了start出发的所有结点
sort(dis+1,dis+1+t);
// pair_num表示点对数量
int pair_num=0;
int i=1,j=t;
// 这个while循环就是把两个dis相加的和小于等于K的点对数量计算出来
while (iK) j--;
pair_num+=j-i;
i++;
}
return pair_num;
}
返回了以点1为重心的树的所有点对数量之后,接下来要减去点1的子树的点对数量。所以我们需要一个递归来进行重复的操作。
// 递归地求每个树的经过重心的点对数量
// 起始点是start
void dfs(int start){
// 以start为起点找到重心
// 注意,虽然一开始我们说了从树的任何一个点开始遍历都能找出一个确定的重心,
// 但是这里的意思是,从start开始,它的父结点就不要再遍历的,只找它和它的子树的重心
getGravity(start,0);
// 用这个重心算出所有跨过该重心的路径数
ans += work(gravity,0);
// 标记这个重心已访问
visited[gravity]=1;
// 从重心开始访问子结点
for(int i=first[start]; i; i=edge[i].next){
int end=edge[i].end;
if (visited[end]){
continue;
}
// 减去以子结点为根的树的所有跨过子结点的路径数
ans -= work(end, edge[i].weight);
// 以子结点为根的树继续求重心、所有跨过路径数
dfs(end);
}
return;
}
然后放上main函数:
int main()
{
// 输入结点个数
scanf("%d",&n);
// 输入每个结点的信息
for(int i=1;i
最后是整体代码:
#include
#define MAX 10000;
// 全局变量
int n; // 所有结点数
int size[n],max_child[n],min=MAX;// 以n为根的树的结点数,以n为根的树的最大子树的结点数
int first[n+1],edge[(n-1)*2]// 顶点表,边表
int visited[n+1];// 标记已访问过的点
int dis[];// 每个点到重心的距离
int gravity=0;// 重心
int num=0,t=0;// 结点编号
int ans=0;// 最终结果:小于等于K的点对数量
void addNodeAndEdge(int start, int end, int weight)
{
num++;// 编号
edge[num].start=start;// 起点
edge[num].end=end;// 终点
edge[num].weight=weight;// 权重
edge[num].next=first[start];// 下一条相同起点的边
first[start]=num;// 加入顶点
}
// 传入重心,以及重心和它的父结点之间的权重
int work(int start,int weight) {
t=0;
// 算出start到各个结点的距离
getDistance(start, 0, weight);
// 得到dis数组后,对其进行从小到大排序
// 注意,这里的t已经不是0了,它是全局变量,在getDistance里面遍历了start出发的所有结点
sort(dis+1,dis+1+t);
// pair_num表示点对数量
int pair_num=0;
int i=1,j=t;
// 这个while循环就是把两个dis相加的和小于等于K的点对数量计算出来
while (iK) j--;
pair_num+=j-i;
i++;
}
return pair_num;
}
void getDistance(int start, int parent, int weight)//fa表示x的父亲,z表示x到目标点的距离
{
// dis数组保存了每个点到重心的距离(权重),为什么用t来做下标而不是点的编号呢
// 因为后面的做法只用数点对的个数,不在乎是谁到谁
dis[++t]=weight;
for(int i=first[start]; i; i=edge[i].next)
{
int end=edge[i].end;
// 如果这个终点已经被遍历或者这个终点是父结点,就跳过
// 这点很重要,因为如果传入的start不是根结点而是根结点的子树的时候
// 它就不会把根结点再遍历一次
if(visited[end] || end==parent){
continue;
}
getDistance(end, start, weight+edge[i].weight);
}
}
// 传入参数
// start 代表当前结点
// parent 代表当前结点的父结点,这里是为了防止遍历start的子结点的时候把父结点也遍历进去了
void getGravity(int start, int parent)
{
// size[start]代表当前结点的个数,初始为1是算上本身
size[start]=1;
// max_child[start]代表当前结点的最大子树结点数
max_child[start]=0;
// 遍历以start结点为起点的所有边(除去连接父结点的边)
for(int i=first[start]; i; i=edge[i].next)
{
// end为当前边的终点(也是start点的子结点)
int end=edge[i].end;
// 如果这个终点已经被遍历或者这个终点是父结点,就跳过
if(visited[end] || end==parent){
continue;
}
// 继续遍历终点的子结点,这里其实就是遍历start的一棵子树的所有结点数
getGravity(end, start);
// 遍历完这棵子树的所有结点后,把子树的结点数加起来
size[start]+=size[end];
// 如果这颗子树的结点数大于max_child,就更新它
if(size[end] > max_child[start]){
max_child[start]=size[end];
}
}
// 上面的循环是用来遍历start的每一棵子树并比较出最大子树结点
// 接下来就是算“红色部分”,也就是n-size[i]部分的结点数,并比较出最终的最大子树结点
if(n-size[start] > max_child[start]){
max_child[start]=n-size[start];
}
// 从max_child中比较出最小的,以找出重心
if(min < max_child[start]){
min=max_child[x];
gravity=start;
}
}
// 递归地求每个树的经过重心的点对数量
// 起始点是start
void dfs(int start){
// 以start为起点找到重心
// 注意,虽然一开始我们说了从树的任何一个点开始遍历都能找出一个确定的重心,
// 但是这里的意思是,从start开始,它的父结点就不要再遍历的,只找它和它的子树的重心
getGravity(start,0);
// 用这个重心算出所有跨过该重心的路径数
ans += work(gravity,0);
// 标记这个重心已访问
visited[gravity]=1;
// 从重心开始访问子结点
for(int i=first[start]; i; i=edge[i].next){
int end=edge[i].end;
if (visited[end]){
continue;
}
// 减去以子结点为根的树的所有跨过子结点的路径数
ans -= work(end, edge[i].weight);
// 以子结点为根的树继续求重心、所有跨过路径数
dfs(end);
}
return;
}
int main()
{
// 输入结点个数
scanf("%d",&n);
// 输入每个结点的信息
for(int i=1;i