[题解] 「NOIP2016」天天爱跑步
(声明:图片来源于网络)
「NOIP2016」天天爱跑步 题解
题目
题目描述
小c同学认为跑步非常有趣,于是决定制作一款叫做《天天爱跑步》的游戏。《天天爱跑步》是一个养成类游戏,需要玩家每天按时上线,完成打卡任务。
这个游戏的地图可以看作一一棵包含\(n\)个结点和\(n-1\)条边的树,每条边连接两个结点,且任意两个结点存在一条路径互相可达。树上结点编号为从\(1\)到\(n\)的连续正整数。
现在有\(m\)个玩家,第\(i\)个玩家的起点为\(t_i\),终点为\(t_i\) 。每天打卡任务开始时,所有玩家在第\(0\)秒同时从自己的起点出发,以每秒跑一条边的速度,不间断地沿着最短路径向着自己的终点跑去,跑到终点后该玩家就算完成了打卡任务。 (由于地图是一棵树,所以每个人的路径是唯一的)
小c想知道游戏的活跃度,所以在每个结点上都放置了一个观察员。在结点\(j\)的观察员会选择在第\(w_j\)秒观察玩家,一个玩家能被这个观察员观察到当且仅当该玩家在第\(w_j\)秒也正好到达了结点\(j\)。小c想知道每个观察员会观察到多少人?
注意:我们认为一个玩家到达自己的终点后该玩家就会结束游戏,他不能等待一 段时间后再被观察员观察到。 即对于把结点\(j\)作为终点的玩家:若他在第\(w_j\)秒前到达终点,则在结点\(j\)的观察员不能观察到该玩家;若他正好在第\(w_j\)秒到达终点,则在结点\(j\)的观察员可以观察到这个玩家。
输入格式
第一行有两个整数\(n\)和\(m\)。其中\(n\)代表树的结点数量,同时也是观察员的数量,\(m\)代表玩家的数量。
接下来\(n−1\)行每行两个整数\(u\)和\(v\),表示结点\(u\)到结点\(v\)有一条边。
接下来一行\(n\)个整数,其中第\(j\)个整数为\(w_j\),表示结点\(j\)出现观察员的时间。
接下来\(m\)行,每行两个整数\(s_i\),和\(t_i\),表示一个玩家的起点和终点。
对于所有的数据,保证\(1\leq s_i\),\(t_i\leq n\), \(0\leq w_j\leq n\)。
输出格式
输出\(1\)行\(n\)个整数,第\(j\)个整数表示结点\(j\)的观察员可以观察到多少人。
输入输出样例
输入 #1
6 3
2 3
1 2
1 4
4 5
4 6
0 2 5 1 2 3
1 5
1 3
2 6
输出 #1
2 0 0 1 1 1
输入 #2
5 3
1 2
2 3
2 4
1 5
0 1 0 3 0
3 1
1 4
5 5
输出 #2
1 2 1 0 1
First
首先看这道题,因为题目说了输入的会是一棵数,而在树中,两点间的最短路径为:起点到达他们的lca,再由lca到达终点。
所以先求出这两点之间的lca,这个很明显。(可以用Tarjan,亦可用倍增,本题解使用倍增求解)
C++代码:
#include <cstdio>
#include <vector>
using namespace std;
const int MAXN = 3e5;
vector<int> v[MAXN];
int W[MAXN], de[MAXN], dp[MAXN][32];
bool vis[MAXN];
int n, m;
int LCA(int x, int y) {
if(de[x] < de[y])
swap(x, y);
for(int i = 30; i >= 0; i--)
if(de[x] - (1 << i) >= de[y])
x = dp[x][i];
if(x == y)
return x;
for(int i = 30; i >= 0; i--) {
if(dp[x][i] != dp[y][i]) {
x = dp[x][i];
y = dp[y][i];
}
}
return dp[x][0];
}
void dfs(int now, int step) {
de[now] = step;
int SIZ = v[now].size();
for(int i = 0; i < SIZ; i++) {
int next = v[now][i];
if(!vis[next]) {
vis[next] = true;
dp[next][0] = now;
dfs(next, step + 1);
}
}
}
void Init() {
vis[1] = true;
dfs(1, 0);
for(int j = 1; j < 31; j++)
for(int i = 1; i <= n; i++)
dp[i][j] = dp[dp[i][j - 1]][j - 1];
}
void Read() {
scanf("%d %d", &n, &m);
for(int i = 1; i < n; i++) {
int A, B;
scanf("%d %d", &A, &B);
v[A].push_back(B);
v[B].push_back(A);
}
for(int i = 1; i <= n; i++)
scanf("%d", &W[i]);
}
void Write() {
for(int i = 1; i <= m; i++) {
int A, B;
scanf("%d %d", &A, &B);
int lca = LCA(A, B);
}
}
int main() {
Read();
Init();
Write();
return 0;
}
在这里不难想到一种暴力跑\(O(nm)\)的算法:
对于\(m\)个玩家\(i\),可以对于每一个观察员来判断在指定时刻到达该点,若到达,则为该观察员做出贡献。
还是可以骗到一些分的。
继续深入的思考一下:有哪些路径是重合的呢?
对于这种做法,当然没有,因为对于每一个节点都有不同的路径,而对于观察员来说两两关联并不是很大,所以这种对于每个玩家来进行贡献统计,不能优化什么。
Second
既然对于每个玩家跑一遍是行不通的,那么可以先转换思路,对于每一个观察员进行统计,看看那些节点对自己做了贡献。
初步地来想,好像也是\(O(nm)\)的暴力做法,求出每个玩家的起点与终点的lca,看是否与自己的时间要求相匹配。若可以匹配,则玩家为自己做出了贡献。
对于这棵树进行dfs,但是如何简化求出对自己做出贡献的节点呢?
情况一:
观察员在起点到lca的路上
如上图,满足上述条件,设e为起点,P为终点,若e为P做了贡献,不难想到需要满足以下条件:
deep[e]=w[P]+deep[P]
(deep为该节点的深度,可在求lca是进行处理)
由于P为e的祖先,所以e,P之间的距离就为deep[e]-deep[P],等于时间×速度,时间为w[P],速度又为1(题目已经给出),所以路程为w[P]。移项就转换为上述条件。
情况二:
观察员在lca到终点的路上
如上图,同理可以求出需要满足该条件:
deep[c]+deep[f]-2*deep[lca]−w[P]=deep[f]−deep[P]
(由于该图是一颗树,所以deep[c]+deep[f]-2*deep[lca] 为c到f的距离,下文使用dist来表示)
Third
应该如何统计那些节点对自己做出了贡献呢?
如果使用枚举的方法,那时间复杂度还是不变。
所以使用一个桶来存储当前访问的贡献值,回溯时就直接调用即可。
方法:
情况一:
很明显,c对于b与a都做出了贡献,满足上述情况。但是需要注意的地方是:
c点不应该为e点做出贡献!
怎么办呢?如何统计无法生效的多做了的贡献。
继续观察上图,可以发现只有桶内原来的值与现在桶内的差值才是所处了的真正贡献。(差分思想)
情况二:
因为对于该访问节点now,若是以now为根的子树,却不经过经过now节点的值,是必不会为该节点做出贡献的。
所以及时统计该子树做出的贡献,再删除该贡献的值,就不会被计入不该计入的树的贡献之中(离开这颗树就什么都不是)。
代码含有注释
C++实现:
#include <cstdio>
#include <vector>
using namespace std;
void Quick_Read(int &N) {
N = 0;
int op = 1;
char x = getchar();
while(!(x >= '0' && x <= '9')) {
if(x == '-')
op = -1;
x = getchar();
}
while(x >= '0' && x <= '9') {
N = (N << 3) + (N << 1) + x - '0';
x = getchar();
}
N * op;
}
const int MAXN = 3e5;
vector<int> v[MAXN], Vend[MAXN], Vlca[MAXN];
int dist[MAXN], s[MAXN], t[MAXN], From[MAXN];
int ans[MAXN];
int bucket1[MAXN], bucket2[MAXN * 2];
int W[MAXN], deep[MAXN], dp[MAXN][32];
bool vis[MAXN];
int n, m;
void DP(int now) {
int Num1 = bucket1[W[now] + deep[now]];//为当前节点做贡献的桶1
int Num2 = bucket2[W[now] - deep[now] + MAXN];//为当前节点最贡献的桶2
int SIZ = v[now].size();
for(int i = 0; i < SIZ; i++) {//向下搜索
int next = v[now][i];
if(dp[now][0] != next)
DP(next);
}
bucket1[deep[now]] += From[now];//统计情况:s->lca,从当前节点出发会统计到的点,为自己的祖先做贡献
SIZ = Vend[now].size();
for(int i = 0; i < SIZ; i++) {//统计情况:lca->t,为自己子孙做贡献
int next = Vend[now][i];
bucket2[dist[next] - deep[t[next]] + MAXN]++;
}
ans[now] += bucket1[W[now] + deep[now]] + bucket2[W[now] - deep[now] + MAXN] - Num1 - Num2;
//在向下遍历子数的过程中,能为now做的贡献,以及自己的祖先为自己做的贡献。
SIZ = Vlca[now].size();
for(int i = 0; i < SIZ; i++) {
int next = Vlca[now][i];
bucket1[deep[s[next]]]--;
//已经做过的贡献就不用再做一次了,否则可能会在遍历其他子树的时候重复遍历,离开子树就贡献就没有意义了
bucket2[dist[next] - deep[t[next]] + MAXN]--;//同上
}
}
void dfs(int now, int step) {
deep[now] = step;
int SIZ = v[now].size();
for(int i = 0; i < SIZ; i++) {
int next = v[now][i];
if(!vis[next]) {
vis[next] = true;
dp[next][0] = now;
dfs(next, step + 1);
}
}
}
int LCA(int x, int y) {
if(deep[x] < deep[y])
swap(x, y);
for(int i = 30; i >= 0; i--)
if(deep[x] - (1 << i) >= deep[y])
x = dp[x][i];
if(x == y)
return x;
for(int i = 30; i >= 0; i--) {
if(dp[x][i] != dp[y][i]) {
x = dp[x][i];
y = dp[y][i];
}
}
return dp[x][0];
}
void Init() {
deep[1] = 1; vis[1] = true; dp[1][0] = 1;
dfs(1, 1);
for(int j = 1; j < 31; j++)
for(int i = 1; i <= n; i++)
dp[i][j] = dp[dp[i][j - 1]][j - 1];
}
void Read() {
Quick_Read(n); Quick_Read(m);
for(int i = 1; i < n; i++) {
int A, B;
Quick_Read(A);
Quick_Read(B);
v[A].push_back(B);
v[B].push_back(A);
}
for(int i = 1; i <= n; i++)
Quick_Read(W[i]);
}
void Player() {
for(int i = 1; i <= m; i++) {
Quick_Read(s[i]);
Quick_Read(t[i]);
int lca = LCA(s[i], t[i]);
dist[i] = deep[s[i]] + deep[t[i]] - 2 * deep[lca];
From[s[i]]++;
Vend[t[i]].push_back(i);
Vlca[lca].push_back(i);
if(deep[lca] + W[lca] == deep[s[i]])//若起点或终点考虑W[lca]后与lca重合,则会重复统计
ans[lca]--;
}
DP(1);
for(int i = 1; i <= n; i++)
printf("%d ", ans[i]);
}
int main() {
Read();
Init();
Player();
return 0;
}
因为该做法只需要便利每一个玩家与观察员,所以时间复杂度为\(O(n+m)\)。
(注意:在统计向下的贡献时,有可能数组下标为负数,加上一个MAXN就可以了。但是即使为负数,也是有意义的,因为这个式子是通过移项所得到的)