写在前面:
本系列博客仅作为本人十一假期过于无聊的产物,对小学期的程序设计作业进行一个总结式的回顾,如果将来有BIT的学弟学妹们在百度搜思路时翻到了这一条博客,也希望它能对你产生一点帮助(当然,依经验来看,每年的题目也会有些许的不同,所以不能保证每一题都覆盖到,还请见谅)。
不过本人由于学艺不精,代码定有许多不足之处,欢迎各位一同来探讨。
同时请未来浏览这条博客的学弟学妹们注意,对于我给出完整代码的这些题,仅作帮助大家理解思路所用(当然,因为懒,所以大部分题我都只给一个伪代码)。Anyway,请勿直接复制黏贴代码,小学期的作业也是要查重的,一旦被查到代码重复会严厉扣分,最好的方法是浏览一遍代码并且掌握相关的要领后自己手打一遍,同时也要做好总结和回顾的工作,这样才能高效地提升自己的代码水平。
加油!
成绩 | 10 | 开启时间 | 2021年09月10日 星期五 11:00 |
折扣 | 0.8 | 折扣时间 | 2021年10月10日 星期日 23:00 |
允许迟交 | 否 | 关闭时间 | 2021年10月10日 星期日 23:00 |
Background
除了上课答疑之外,给同学们出题也是小学期助教的工作之一,今天又轮到Roark出题了。
Roark本打算出这样一道题:
给出一个有个节点,条边的连通图,每条边长均为1。求对于所有的点对,,其中指的是之间的最短距离。
但当他把题目说给DarkDown时,却得到了这样的答复:
Roark自然不敢违抗DarkDown的命令T^T,只好稍稍增加了一点难度,题目就变成了现在的样子。
Description
给出一个有个节点,条边的连通图,每条边长均为1。除此之外,现在原图上的每两个不相邻且仅间隔一个节点的节点间建边,边长仍为1。求对于所有的点对,,其中指的是之间的最短距离。
Input
第一行输入,表示节点数
接下来行每行两个整数表示原图在节点与节点之间有一条边。
Output
输出
测试用例 1 | 以文本方式显示
| 以文本方式显示
| 1秒 | 64M | 0 |
测试用例 2 | 以文本方式显示
| 以文本方式显示
| 1秒 | 64M | 0 |
题意分析:
感觉有机会竞争一下整个小学期最难的一道题。(然而是我少见的一次AC的题,笑)
想做出来这题首先你得对图、树的概念非常的明确,如果还不熟悉的同学建议先去复习一下(名词解释:图、名词解释:树)。了解了基本概念之后,你就可以理解,这道题本质上不是一道“图”的题,而是一道“树”的题——用图的方向来做的话,这题是大概率要超时的。
说回题目本身,我们先考虑加难度之前的题目,即“给出一个有个节点,条边的连通图,每条边长均为1。求对于所有的点对,,其中指的是之间的最短距离” 先研究清楚这道题怎么做,再考虑加难度之后需要做哪些调整。
题目里告诉了我们这个图是一个“连通图”,顾名思义,就是所有的点都连在了一起。然而,我们用数学知识(以及我们聪明的小脑瓜子)不难知道,对于一个有n个点的连通图,至少需要n-1条边才能把他们全部连起来——这题正好只有n-1条边。所以题目给出来的图本质上就是一个最简单的连通图,无环且无向——这就是树。
对于上面这段话不太理解的同学可以自己试着画一画n个节点、n-1条边的图,看看能不能画出环来。
我们确定了这是一棵树之后,就可以试着来计算所谓的值了。要怎么做呢?
第一个蹦进脑海的自然思路,是用遍历所有点对,然后用某种方法求出这个点对之间的距离。想都不用想就知道不行,因为这个的遍历就已经是我们所不能承受的了,更不必谈计算两点间距离还需要耗费额外的时间,这个思路直接pass掉。
那么,既然从点对出发不行,我们能不能试试从边出发,考虑某一条最短边(即题目给出的“输入”)被走了几次呢?因为只有n-1条最短边,这样就能大大减少我们在遍历上耗费的时间。但是从边出发有个问题,就是我们该如何确定有哪些点对之间的距离需要经过这条边呢?
现在你应该知道我们为什么前面要证明这个图是一棵树了。
如上边这棵树所示,我想知道1—4这条边有多少个点对走过,要如何确定?
实际上,我们只需要注意这条边“左侧”的节点数量(包括这条边的左端点,下同),和“右侧”的节点数量,左侧的任意一个节点与右侧任意一个节点之间的路线都必然要经过这条边,而同侧的两个节点之间则必然不经过这条边,于是我们只需要遍历所有的最短边然后找到这条边两端各自的节点数量就好。
那么这个节点数量又如何确定?其实就很简单了,我们知道,对于连通图,一个节点要么在这条边的一端、要么在另一端(废话),因此我们只要确定一端的节点数,再用总数减去它就是另一端的节点数。而对于“树”中间的一条边,必然是父子关系,其中子节点那一端的节点数就是子树的大小。例如对于1—4这条边,4是子节点,4这棵子树的大小为4,于是另一端的节点数量即为10-4=6,于是经过这条边的点对总数应为46=24,也就是说,这一条边对距离总和的贡献为24。
那么我们现在已经有能力对于一棵给定的数,求出了,现在摆在我们面前的问题就是如何把一张给定的连通图转化成一棵树——或者,如何用树的思想来对图进行操作。
世间万物都是这样,你可以选择改变它,也可以选择改变你的思想。——沃兹基硕德
用树的思想来对图进行操作,很容易想到的就是深度优先搜索(所以我也不知道为什么一道DFS的题目要放在DP这个单元里,我似乎是没有打听到这一题的DP做法)。那么我们要搜索的东西是什么呢?根据我们前面这么多推理过程,不难发现,我们需要搜索的应该是以某个点为顶点的子树的大小,那事情就好办了,一切按照递归来走,每棵子树的大小 = 子树大小 + 1(顶点)——对于图而言,我们就是找遍历每个节点A的邻接表,如果表中的某点B没有被遍历过,那就把这个节点B当做A的子节点,再递归地搜索这个B对应的子树大小,终止条件为某个点的邻接表内所有点都已经被遍历过,代表这个点是叶子结点,其大小为1。
现在我们已经完全有能力做出难度升级前的原题了,我们可以试着挑战一下难度升级后的了。
还是这幅图,题目变化前和变化后有啥区别呢?
“现在原图上的每两个不相邻且仅间隔一个节点的节点间建边,边长仍为1”,这句话就意味着,原本所有距离为2的点对,现在都新建一条距离为1的边——如果你真的去新建了边,那这题基本就没法做了,因为这样就出现了环,树的结构就不成立了,我们肯定不希望这样。还是前面那句话:
世间万物都是这样,你可以选择改变它,也可以选择改变你的思想。——沃兹基尤硕勒逸卞
我们能不能在原本题目的基础上,多做点手脚,来应对新题呢?
肯定是可以的,不然我前面废话那么多讲原题是为了啥
我们发现,这个新建边的唯一影响就是,原本距离为n的两个点,现在的距离为(向上取整)。对于上面那副图而言,1——9的距离本来是2,但由于我们在距离为2的两点新建了边,距离就成了1;于是,1——10的距离就因为1——9的距离减少而一起减少,变为了2(可以是1—9—10,也可以是1—4—10);类似的,1——11的距离就得益于1——9和9——11的减少而减少,也变为了2。总而言之言而总之,原本距离为n的两个点,现在的距离为(向上取整)。
这时候就有人会问了“那这有什么难的啊,我把总数除以二再向上取整不就好了吗?!”
我们就要注意到,这个“取整”是在每个距离的计算过程中取整,而非在最后取整。举个例子,4——11,4——12两条边,原本的距离都为3,如今的距离都为2,所以新的距离和是4,如果按照原本距离和除以二再取整则会得到3,显然是不正确的。
那么这个新的计算公式究竟应该是什么呢?用我们的数学知识,不难推出,应该是 偶数步长的距离之和除以二 + (奇数步长的距离之和 + 奇数步长的边数)除以2,此处距离均指原题的距离,同时两个除都必然是整除(可以想想为什么)。这个公式还可以再进行优化,变为(总距离之和 + 奇数步长的边数)除以2。
行百里者半九十,到了现在,我们已经拿到了总距离之和,我们要处理的最后一个问题,就是如何确定“奇数步长的边”的数量?
又双叒叕是这幅图,我们考虑一下什么情况下两点之间的原距离是奇数。我们先考虑一下两点之间原距离是怎么算的吧,我们前面给出了思路只能计算总共的距离和,而无法精确计算两点之间的最短距离。而一棵树两点之间的最短距离,就是两点到他们的最近公共祖先距离(LCA)之和(名词解释:最近公共祖先)。举个例子,要计算节点8与11的距离,就要找到他们的LCA——节点4,而8到4的原距离是1,11到4的原距离是3,二者之和就是8到11的距离,也就是4。这个公式依然不利于我们判断奇偶性,我们还可以再展开一点,即,也就是两个点的深度之和减去它们LCA的深度的两倍。这个公式其实非常好理解,因为点u到它们的LCA的距离就是u的深度减去LCA的深度,点v也同样,二者相加就得到这个公式了。
这个公式对于我们有什么好处呢?我们容易注意到,后面这个项必然是偶数,于是整个式子的奇偶性完全取决于前面这两个项——如果一个点是奇数深度,另一个是偶数深度,结果就是奇数,否则就是偶数。于是我们不难知道,最终的奇数步长的边数应该等于 奇数节点的数量偶数节点的数量,因为当且仅当一个节点来自奇数节点集合,另一个来自偶数节点集合时,这条边才是我们想要的奇数步长边,于是总共满足条件的点对就这么多。
回到我们的公式,新距离 =(原总距离之和 + 原奇数步长的边数)除以2,同时对于原距离的求和我们也能够完成了,现在就可以着手开始写代码了。
伪代码:
(由于后面有详细代码,这里就简单点写了,具体的实现过程都在上面了)
读入数据,保存所有的读入的边(保存这个操作不是必要的,但是为了后面遍历边方便,保存了也不会有问题,毕竟这题的空间限制并不严格);
对所有读入的边建立邻接表,用来表示图;
对图进行DFS,确定每一个节点的深度,并且在过程中统计深度为奇数的节点;
遍历每一条边,计算这条边对的贡献,记为;
计算奇数步长边的数量(也就是奇数节点的数量偶数节点的数量,偶数节点的数量可以用总数减去奇数节点的数量得到),也加到里;
除以2,输出;
贴代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
const int INF = 0x3f3f3f;
const double EPS = 1e-8;
const double PI = acos(-1);
#define __MAX 200010
#define __BASE 2147483647
vector<int> link[__MAX]; //邻接表
vector<pii> side; //用于保存输入的边
int child[__MAX]; //用于保存子树的大小(即子节点数量+1)
int dep[__MAX];
/*用于保存节点的深度,事实上,这里不需要这样做,只需要在过程中传递“是否为奇数节点”这个变量,
并且累加到count_odd上就行,感兴趣的同学可以试试dfs(int cur, bool is_odd)这样的写法*/
int count_odd = 0; //奇数节点的数量
inline int read() //快读,为了防止超时而做的一点优化,有大量数据输入的时候比scanf和cin快很多,事后发现应该没有这个必要
{
int x=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9')
{
if(ch=='-')
f=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9')
{
x=x*10+(ch-'0');
ch=getchar();
}
return f*x;
}
int main(){
///ifstream infile("input.txt", ios::in);
///ofstream outfile("output.txt", ios::out);
int n;
void dfs(int cur, int depth);
cin >> n;
memset(dep, -1, sizeof(dep));
for(int i = 0; i < n - 1; i++){
int u, v;
u = read();
v = read();
link[u].push_back(v); //更新邻接表
link[v].push_back(u);
side.emplace_back(u,v); //保存输入边
}
dfs(1,1); //假定第一个节点为根节点,深度为1,开始DFS,事实上,这里的根节点可以任意设置,初始深度也可以任意设置,可以自己想想为什么
ll res = 0; //记得开long long
for(pii line: side){ //遍历所有输入的边,如果没有保存输入的话,这里再用一次DFS遍历也是可以的
//ll anc = max(child[line.first], child[line.second]);
ll son = min(child[line.first], child[line.second]);
res += son * (n - son);
}
res += (ll)count_odd * (ll)(n - count_odd); //涉及到整型变量相乘溢出,记得先做类型转换
cout << res / 2 << endl;
return 0;
}
void dfs(int cur, int depth){
int total_child = 0;
dep[cur] = depth;
if(depth & 1) count_odd += 1; //判断是否为奇数,用位运算更快
for(int i: link[cur]){
if(dep[i] != -1) continue; //深度不为-1,说明已遍历过,跳过
else{ //未遍历过,将其纳入cur节点的子节点中
dfs(i, depth + 1);
total_child += child[i]; //递归地计算总节点数
}
}
child[cur] = total_child + 1; //加上节点自己
}