更多 CSP 认证考试题目题解可以前往:CCF-CSP 认证考试真题题解
原题链接: 202312-3 树上搜索
时间限制: 1.0s
内存限制: 512.0MB
题目背景
西西艾弗岛大数据中心为了收集用于模型训练的数据,推出了一项自愿数据贡献的系统。岛上的居民可以登录该系统,回答系统提出的问题,从而为大数据中心提供数据。为了保证数据的质量,系统会评估回答的正确性,如果回答正确,系统会给予一定的奖励。
近期,大数据中心需要收集一批关于名词分类的数据。系统中会预先设置若干个名词类别,这些名词类别存在一定的层次关系。例如,“动物”是“生物”的次级类别,“鱼类”是“动物”的次级类别,“鸟类”是“动物”的次级类别,“鱼类”和“鸟类”是“动物”下的邻居类别。这些名词类别可以被按树形组织起来,即除了根类别外,每个类别都有且仅有一个上级类别。
并且所有的名词都可以被归类到某个类别中,即每个名词都有且仅有一个类别与其对应。一个类别的后代类别的定义是:若该类别没有次级类别,则该类别没有后代类别;否则该类别的后代类别为该类别的所有次级类别,以及其所有次级类别的后代类别。
下图示意性地说明了标有星号的类别的次级类别和后代类别。
次级类别与后代类别
系统向用户提出问题的形式是:某名词是否属于某类别,而用户可以选择“是”或“否”来回答问题。该问题的含义是:某名词是否可以被归类到某类别或其后代类别中。
例如,要确定名词“鳕鱼”的类别,系统会向用户提出“鳕鱼是否属于动物”,当用户选择“是”时,系统会进一步询问“鳕鱼是否属于鱼类”,当用户选择“是”时,即可确定“鳕鱼”可以被归类到“鱼类”这一类别。
此外,如果没有更具体的分类,某一名词也可以被归类到非叶子结点的类别中。例如,要确定“猫”的类别,系统可以向用户提出“猫是否属于动物”,当用户选择“是”时,系统会进一步分别询问“猫”是否属于“鱼类”和“鸟类”,当两个问题收到了否定的答案后,系统会确定“猫”的类别是“动物”。
大数据中心根据此前的经验,已经知道了一个名词属于各个类别的可能性大小。为了用尽量少的问题确定某一名词的类别,大数据中心希望小 C 来设计一个方法,以减少系统向用户提出的问题的数量。
问题描述
小 C 观察了事先收集到的数据,并加以统计,得到了一个名词属于各个类别的可能性大小的信息。具体而言,每个类别都可以赋予一个被称为权重的值,值越大,说明一个名词属于该类别的可能性越大。由于每次向用户的询问可以获得两种回答,小 C 联想到了二分策略。他设计的策略如下:
对于每一个类别,统计它和其全部后代类别的权重之和,同时统计其余全部类别的权重之和,并求二者差值的绝对值,计为
w
δ
w_{\delta}
wδ;
选择
w
δ
w_{\delta}
wδ 最小的类别,如果有多个,则选取编号最小的那一个,向用户询问名词是否属于该类别;
如果用户回答“是”,则仅保留该类别及其后代类别,否则仅保留其余类别;
重复步骤 1,直到只剩下一个类别,此时即可确定名词的类别。
小 C 请你帮忙编写一个程序,来测试这个策略的有效性。你的程序首先读取到所有的类别及其上级次级关系,以及每个类别的权重。你的程序需要测试对于被归类到给定类别的名词,按照上述策略提问,向用户提出的所有问题。
输入格式
从标准输入读入数据。
输入的第一行包含空格分隔的两个正整数 n n n 和 m m m,分别表示全部类别的数量和需要测试的类别的数量。所有的类别从 1 1 1 到 n n n 编号,其中编号为 1 的是根类别。
输入的第二行包含 n n n 个空格分隔的正整数 w 1 , w 2 , … , w n w_1, w_2, \dots, w_n w1,w2,…,wn,其中第 i i i 个数 w i w_i wi 表示编号为 i i i 的类别的权重。
输入的第三行包含 ( n − 1 ) (n-1) (n−1) 个空格分隔的正整数 p 2 , p 3 , … , p n p_2, p_3, \dots, p_n p2,p3,…,pn,其中第 i i i 个数 p i + 1 p_{i+1} pi+1 表示编号为 ( i + 1 ) (i+1) (i+1) 的类别的上级类别的编号,其中 p i ∈ [ 1 , n ] p_{i} \in [1, n] pi∈[1,n]
接下来输入 m m m 行,每行一个正整数,表示需要测试的类别编号。
输出格式
输出 m m m 行,每行表示对一个被测试的类别的测试结果。表示按小 C 的询问策略,对属于给定的被测类别的名词,需要依次向用户提出的问题。
每行包含若干空格分隔的正整数,每个正整数表示一个问题中包含的类别的编号,按照提问的顺序输出。
样例1输入
5 2
10 50 10 10 20
1 1 3 3
5
3
样例1输出
2 5
2 5 3 4
样例解释
上述输入数据所表示的类别关系如下图所示,同时各个类别的权重也标注在了图上。
样例输入数据所表示的类别关系
对于归类于类别 5 的某个名词,按照上述询问策略,应当对于树上的每个节点,都计算 w δ w_{\delta} wδ 的值,对于类别 1 至 5,得到的 w δ w_{\delta} wδ 分别为:100、0、20、80、60。因此首先就类别 2 提问。由于类别 5 不属于类别 2 的后代类别,因此用户回答“否”,此时去除类别 2 和其全部后代类别,仅保留类别 1、3、4、5。对于剩下的类别,计算 w δ w_{\delta} wδ 的值,得到的 w δ w_{\delta} wδ 分别为:50、30、30、10。因此再就类别 5 提问。由于类别 5 就是被提问的名词所属类别,因此用户回答“是”,此时仅保留类别 5 和其全部后代类别。我们发现,这个时候,只剩下类别 5,因此算法结束。上述过程如下图所示:
算法执行过程 1
对于归类于类别 3 的某个名词,按照上述询问策略,依次对类别 2、5 提问,过程与前述一致。但是由于类别 3 不属于类别 2 的后代类别,用户回答“否”,此时应当去掉类别 5 和其后代类别,仅保留类别 1、3、4。分别计算 w δ w_{\delta} wδ 得:30、10、10。此时应当选择编号较小的类别 3 提问。由于类别 3 就是被提问的名词所属类别,因此用户回答“是”,此时仅保留类别 3 和其全部后代类别。我们发现,这个时候,并非只剩下一个类别,因此算法还应继续进行。剩下的类别有 3、4,分别计算 w δ w_{\delta} wδ 得:20、0。因此再就类别 4 提问。由于类别 3 不属于类别 4 的后代类别,用户回答“否”,此时应当去掉类别 4 和其后代类别,仅保留类别 3。我们发现,这个时候,只剩下类别 3,因此算法结束。上述过程如下图所示:
算法执行过程 2
子任务
对 20% 的数据,各个类别的权重相等,且每个类别的上级类别都是根类别;
对另外 20% 的数据,每个类别的权重相等,且每个类别至多有一个下级类别;
对 60% 的数据,有 n ≤ 100 n \le 100 n≤100,且 m ≤ 10 m \le 10 m≤10;
对 100% 的数据,有 n ≤ 2000 n \le 2000 n≤2000, m ≤ 100 m \le 100 m≤100,且 w i ≤ 1 0 7 w_i \le 10^7 wi≤107。
题解
大模拟。
记 W[i]
为节点
i
i
i 以及以
i
i
i 为根的子树的所有剩余节点的权值和。可以在每次删除一个节点后使用 dfs 更新该数组。
每次从根节点 dfs 更新 W[i]
的时候同时将遍历到的结点的 vis
数组的值
+
1
+1
+1,由于根节点是必定存在的(可能过程中会被删除,但是删除完后一定会有一个新的根节点出现),所以仅需比较节点
i
i
i 的 vis[i]
与根节点的 vis[rt]
是否相等,即可判断当前提问轮次中节点
i
i
i 是否存在。
每次找 w δ w_\delta wδ 最小的存在的节点作为这一轮中用户的询问。对于用户的每次询问:
- 如果一个询问
i
i
i 得到的结果为
Yes
,那么仅保留以 i i i 为根的子树,删除其余部分,即将根节点rt
赋值为 i i i; - 如果得到的结果为
No
,那么保留其余部分,删除 i i i 以及以 i i i 为根的子树,可以将 i i i 节点的vis
赋值为 − 1 -1 −1,在 dfs 过程中遇到vis
为 − 1 -1 −1 的节点直接返回。
最终能够确定所属的分类编号时,一定仅剩下一个节点,可以转换为等价条件:
- 根节点为测试节点;
- 根节点的所有子节点已经被提问过,即排除了所有子节点的情况。
时间复杂度: O ( n 2 m ) \mathcal{O}(n^2m) O(n2m)。
参考代码(15ms,7.515MB)
/*
Created by Pujx on 2024/2/2.
*/
#pragma GCC optimize(2, 3, "Ofast", "inline")
#include <bits/stdc++.h>
using namespace std;
#define endl '\n'
//#define int long long
//#define double long double
using i64 = long long;
using ui64 = unsigned long long;
using i128 = __int128;
#define inf (int)0x3f3f3f3f3f3f3f3f
#define INF 0x3f3f3f3f3f3f3f3f
#define yn(x) cout << (x ? "yes" : "no") << endl
#define Yn(x) cout << (x ? "Yes" : "No") << endl
#define YN(x) cout << (x ? "YES" : "NO") << endl
#define mem(x, i) memset(x, i, sizeof(x))
#define cinarr(a, n) for (int i = 1; i <= n; i++) cin >> a[i]
#define cinstl(a) for (auto& x : a) cin >> x;
#define coutarr(a, n) for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n]
#define coutstl(a) for (const auto& x : a) cout << x << ' '; cout << endl
#define all(x) (x).begin(), (x).end()
#define md(x) (((x) % mod + mod) % mod)
#define ls (s << 1)
#define rs (s << 1 | 1)
#define ft first
#define se second
#define pii pair<int, int>
#ifdef DEBUG
#include "debug.h"
#else
#define dbg(...) void(0)
#endif
const int N = 2e5 + 5;
//const int M = 1e5 + 5;
const int mod = 998244353;
//const int mod = 1e9 + 7;
//template <typename T> T ksm(T a, i64 b) { T ans = 1; for (; b; a = 1ll * a * a, b >>= 1) if (b & 1) ans = 1ll * ans * a; return ans; }
//template <typename T> T ksm(T a, i64 b, T m = mod) { T ans = 1; for (; b; a = 1ll * a * a % m, b >>= 1) if (b & 1) ans = 1ll * ans * a % m; return ans; }
int w[N], fa[N];
i64 W[N];
int n, m, t, k, q;
vector<int> g[N];
vector<int> vis;
i64 calc(int u) {
W[u] = w[u];
vis[u]++;
for (auto v : g[u])
if (vis[v] != -1)
W[u] += calc(v);
return W[u];
}
void work() {
cin >> n >> m;
cinarr(w, n);
for (int i = 2; i <= n; i++) cin >> fa[i], g[fa[i]].emplace_back(i);
while (m--) {
int tar; cin >> tar;
int rt = 1, cnt = g[tar].size();
vis.assign(n + 1, 0);
set<int> belong; // 回答 yes 的集合
for (int cur = tar; cur; cur = fa[cur]) belong.insert(cur);
while (cnt || rt != tar) {
calc(rt);
vector<i64> delta(n + 1);
for (int i = 1; i <= n; i++) delta[i] = abs(W[i] - (W[rt] - W[i]));
int idx = rt;
for (int i = 1; i <= n; i++)
if (vis[i] == vis[rt] && delta[i] < delta[idx]) idx = i;
cout << idx << ' ';
if (belong.count(idx)) rt = idx;
else vis[idx] = -1;
if (fa[idx] == tar) cnt--;
}
cout << endl;
}
}
signed main() {
#ifdef LOCAL
freopen("C:\\Users\\admin\\CLionProjects\\Practice\\data.in", "r", stdin);
freopen("C:\\Users\\admin\\CLionProjects\\Practice\\data.out", "w", stdout);
#endif
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int Case = 1;
//cin >> Case;
while (Case--) work();
return 0;
}
/*
_____ _ _ _ __ __
| _ \ | | | | | | \ \ / /
| |_| | | | | | | | \ \/ /
| ___/ | | | | _ | | } {
| | | |_| | | |_| | / /\ \
|_| \_____/ \_____/ /_/ \_\
*/
关于代码的亿点点说明:
- 代码的主体部分位于
void work()
函数中,另外会有部分变量申明、结构体定义、函数定义在上方。#pragma ...
是用来开启 O2、O3 等优化加快代码速度。- 中间一大堆
#define ...
是我习惯上的一些宏定义,用来加快代码编写的速度。"debug.h"
头文件是我用于调试输出的代码,没有这个头文件也可以正常运行(前提是没定义DEBUG
宏),在程序中如果看到dbg(...)
是我中途调试的输出的语句,可能没删干净,但是没有提交上去没有任何影响。ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
这三句话是用于解除流同步,加快输入cin
输出cout
速度(这个输入输出流的速度很慢)。在小数据量无所谓,但是在比较大的读入时建议加这句话,避免读入输出超时。如果记不下来可以换用scanf
和printf
,但使用了这句话后,cin
和scanf
、cout
和printf
不能混用。- 将
main
函数和work
函数分开写纯属个人习惯,主要是为了多组数据。