通知村民 · 题解
1. 思路
把最终的通知方式看成一棵(一批)树:每个人要么由 33DAI 亲自通知(花 ), 要么由某个已经知道通知的村民 转告(花 )——于是每个人都恰好有一个"上级", 顺着上级走一定停在某个被亲自通知的人身上(不会绕圈)。 每个村民 最多有 个"下级"。
设最后有 个人是被亲自通知的,另外 个人是被转告的,那么
$$\text{总花费} = k\cdot p + \sum_{\text{被转告的每个人 } x} b_{\text{上级}(x)}.$$也就是说:每一次转告都是"买一个名额",买谁的名额就付谁的 , 每个村民最多卖出 个名额;卖名额的人自己也必须被通知到。
- 至少要有一个名额来自"亲自通知"的人,所以 ,即答案里至少有一个 ;
- 想让 尽量小,显然要先买最便宜的名额:把村民按 升序排, 依次用掉他们的 个名额;
- 若某个名额的价格已经不比自己通知便宜(),那就不该买它—— 剩下的 个人还不如 33DAI 亲自通知(每人 )。
于是:先花一次 亲自通知一个人(哪个都行,取 最小的那个最自然), 然后按 从小到大遍历,只要 就买下 个名额, 遇到第一个 就停下,剩下的村民全部由 33DAI 亲自通知。
和"没买名额"的方案比:如果所有 都 ,答案就是 ; 否则上面的做法会把名额一直买到"不划算"为止,恰好是两种方案的最优折中。
2. 复杂度
每个测试用例排序 ,之后线性扫一遍;总复杂度 ,空间 。
3. 实现要点
- 答案需要 64 位: 且 , 最大 ,
超出
int,所以累加用long long(中间量use * b也要用long long)。 - 别忘了最开始那一次 :别人能转告的前提是"已经有人知道了", 所以循环开始前答案先加上一个 、剩余人数从 开始。
- 边界: 时答案是 ; 时所有 ,一名名额都不买,答案是 。
- 两行数组的读入: 和 都在各自的一整行里,先读完 个 ,再读 个 。
- 只有一次
sort,多组数据共用一个vector即可。
4. 参考程序(与 src/std.cpp 一致)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100000;
struct Person
{
long long a, b; // a:这个人最多能转告多少名村民;b:他每转告一人的花费
};
bool cmpPerson(const Person &x, const Person &y)
{
return x.b < y.b;
}
int main()
{
// 文件 IO:文件名与题面、config.yaml 的 filename 一致
freopen("spread.in", "r", stdin);
freopen("spread.out", "wb", stdout);
int t;
scanf("%d", &t);
vector<Person> per(MAXN + 5); // per[0..n-1]:按 b 从小到大排好的村民
while (t--)
{
int n;
long long p;
scanf("%d%lld", &n, &p);
for (int i = 0; i < n; i++)
scanf("%lld", &per[i].a);
for (int i = 0; i < n; i++)
scanf("%lld", &per[i].b);
sort(per.begin(), per.begin() + n, cmpPerson);
// 至少要有一个人是被 33DAI 亲自通知的(所以先付一次 p)
long long ans = p;
long long left = n - 1; // 除第一个之外还没被通知到的村民数
for (int i = 0; i < n && left > 0; i++)
{
if (per[i].b >= p)
break;
long long use = min(per[i].a, left);
ans += use * per[i].b;
left -= use;
}
ans += left * p; // 剩下的人由 33DAI 亲自通知,每人 p
printf("%lld\n", ans);
}
return 0;
}
5. 子任务说明
| 子任务 | 测试点 | 限制 | 想放过的写法 |
|---|---|---|---|
| 1 | 1 ~ 6 | 朴素做法:每次线性找当前最便宜的可买名额, 也能过 | |
| 2 | 7 ~ 12 | 所有 | 不必判断"买名额 vs 亲自通知",直接一路买最便宜的名额即可 |
| 3 | 13 ~ 20 | 无额外限制, 卡满 | 正解;还要处理 、、 与 64 位答案 |
前缀和之谜 · 题解
1. 思路
给出的 个数是 ,它们把序列 的尾部完全确定了下来:
于是有两个必要条件:
- 尾部必须不降:, 也就是这些相邻差 必须单调不降;
- 首段必须装得下:剩下的 个元素()之和恰好是 ,而它们每一个都不能超过 。 由于整数序列里首段元素可以取任意小,所以只要 就一定能构造出来。
两条都满足时,直接令
$$a_1 = s_{n-k+1} - (n-k)\cdot d,\qquad a_2 = a_3 = \dots = a_{n-k+1} = d$$(其中 )即可,序列不降且后缀前缀和与输入一致。
时没有任何差分被确定,取 、其余元素随便取(例如都取 )就行,答案恒为 Yes。
2. 复杂度
每个测试用例 ,所有测试用例合计 ;空间 。
3. 实现要点
- 容量判定里 最大约 ,
必须用
long long;差分本身也能到 ,同样不能落到int。 k = 1要单独处理,否则会去读第二个前缀和。- 多组数据共用一个
s数组即可,读入时只读 个。
4. 参考程序(与 src/std.cpp 一致)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100000;
int main()
{
freopen("prefix.in", "r", stdin);
freopen("prefix.out", "wb", stdout);
int t;
scanf("%d", &t);
vector<long long> s(MAXN + 5); // s[0..k-1]:本题给出的最后 k 项前缀和
while (t--)
{
int n, k;
scanf("%d%d", &n, &k);
for (int i = 0; i < k; i++)
scanf("%lld", &s[i]);
bool ok = true;
if (k >= 2)
{
long long len = n - k + 1; // 前缀和没有给出的那一段长度(首段)
long long first = s[1] - s[0]; // 首段每个元素的上界
if (s[0] > len * first) // 首段全取上界也凑不出 s[0],则不可能
ok = false;
for (int i = 2; i < k; i++) // 已知部分被差分唯一确定,差分必须不降
if (s[i] - s[i - 1] < s[i - 1] - s[i - 2])
ok = false;
}
printf("%s\n", ok ? "Yes" : "No");
}
return 0;
}
5. 子任务说明
| 子任务 | 测试点 | 限制 | 想放过的写法 |
|---|---|---|---|
| 1 | 1 ~ 6 | ,数值也很小 | 枚举 / 手动构造首段 |
| 2 | 7 ~ 12 | 只需检查差分不降(首段长度恒为 1) | |
| 3 | 13 ~ 20 | 无额外限制, 卡满 | 正解;两条必要条件一条都不能少,且必须用 64 位 |
城市天际线 题解
思路
1. 每个位置加盖多少层是独立的
设 表示"只把第 栋楼房顶成波峰"所需加盖的层数:
$$c_i = \max\bigl(0,\ \max(h_{i-1}, h_{i+1}) + 1 - h_i\bigr),\qquad i = 2, 3, \dots, n-1$$(街道两端的楼房不可能比街道外面更高)。
关键观察:要顶成波峰,只需要第 栋比"原来"的左右两栋高,不必关心左右两栋有没有被加盖过 (题面只说加盖不能拆楼,别的位置盖多高都不影响"第 栋比它们高"这件事)。 所以选定一组位置 之后,总加盖层数恰好就是 ,各位置之间互不影响。
2. 最多能把几栋变成酷的
酷的楼房必须比左右两栋都高,所以两个酷的楼房不能相邻;又只有第 栋可能变酷。 从位置 里挑两两不相邻的位置,最多能挑
个。这个上界是能达到的:
- 为奇数:位置 一共恰好 个,两两不相邻;
- 为偶数:位置 一共恰好 个,两两不相邻。
于是问题变成:从 里挑 个两两不相邻的位置,让 最小。
3. 大小为 的最优位置集合长什么样
-
为奇数:位置 恰好 个且两两不相邻,已经取满 个; 任何别的大小为 的集合都会多占一个奇数位置,必然和相邻的偶数位置冲突。 所以方案唯一,答案就是这些位置的 之和。
-
为偶数:记
- 偶位置 ( 个),
- 奇位置 ( 个)。
大小为 的合法集合一定形如" 里前 个"并上" 里后 个"()。 于是枚举 ,用前后缀和 算出总代价,取最小值即可。
为什么正好是这种形状: 里相邻两个相差 , 里相邻两个也相差 , 所以"取 的前 个"与"取 的后 个"拼起来恰好 个、且两两不相邻。 反过来,任何 个两两不相邻的位置,把它们按奇偶分类后一定呈这种"左边偏偶、右边偏奇"的形态 (若某个偶位置被跳过、它右边又有奇位置被跳过,个数就凑不满 )。
算法与复杂度
对每组数据:
- 预处理 ;
- 预处理两个前后缀和:
pre[i]:偶数位置 的 之和;suf[i]:奇数位置 的 之和。
- 为奇数 → 答案 ; 为偶数 → 答案 $=\min\limits_{j=0}^{k}\bigl(pre[2j] + suf[2j+3]\bigr)$。
时间复杂度 (每个测试点),空间复杂度 ; 所有测试用例的 之和不超过 ,总时间绰绰有余。
实现要点
- 答案要开
long long。 时答案能达到 量级(例如 这种"偶低奇高"的形状,每个偶数位置的代价都是 量级),int会溢出。 - 用
long long算: 最大到 。 - 偶数 的枚举里 从 到 , 的下标最大是 ,
数组要开到 ,并把
suf[n+1] = suf[n+2] = 0作为哨兵。 - 偶数 时不能只比较"全取偶位置"和"全取奇位置"两组: 最优集合常常需要在中途切换一次奇偶(见下面"数据设计说明"里的错解 A)。
- 为奇数时方案唯一,可以直接求和,不必走枚举。
参考实现
#include <bits/stdc++.h>
using namespace std;
// 城市天际线(CF1706C):
// cost[i] = max(0, max(h[i-1], h[i+1]) + 1 - h[i]) —— 让第 i 栋变酷需要加盖的层数
// (各个位置的加盖量互不影响:第 i 栋只需比原来左右两栋高,不必管别的楼房被加盖了多少)。
// 酷的楼房两两不相邻,且只有第 2 ~ n-1 栋可能变酷,所以酷楼房的数量最多是
// k = floor((n-1)/2):
// n 为奇数时只有 {2, 4, ..., n-1} 这一种方案;
// n 为偶数时记 k = n/2 - 1,偶位置 E = {2, 4, ..., n-2}、奇位置 O = {3, 5, ..., n-1}
// 各有 k 个,任何大小为 k 的合法集合都是「E 的前 j 个」并上「O 的后 k-j 个」,
// 所以枚举 j = 0..k 取最小值即可。
const int MAXN = 100000; // 单组 n 的上限
const long long MAXH = 1000000000LL; // 层数上限 10^9
int n;
long long h[MAXN + 5];
long long costAt[MAXN + 5]; // costAt[i]:让第 i 栋变酷需要加盖的层数
long long pre[MAXN + 5]; // pre[i]:≤ i 的偶数位置的 costAt 之和
long long suf[MAXN + 5]; // suf[i]:≥ i 的奇数位置的 costAt 之和
// 让第 i 栋楼房严格高于原来的左右两栋所需加盖的层数
long long coolCost(int i)
{
long long need = max(h[i - 1], h[i + 1]) + 1;
if (need <= h[i])
return 0;
return need - h[i];
}
long long solve()
{
for (int i = 2; i <= n - 1; i++)
costAt[i] = coolCost(i);
// pre[i]:偶数位置 2, 4, ... 里不超过 i 的那些位置代价之和
pre[0] = 0;
pre[1] = 0;
for (int i = 2; i <= n + 1; i++)
{
pre[i] = pre[i - 1];
if (i <= n - 1 && i % 2 == 0)
pre[i] += costAt[i];
}
// suf[i]:奇数位置 3, 5, ... 里不小于 i 的那些位置代价之和
suf[n + 1] = 0;
suf[n + 2] = 0;
for (int i = n; i >= 1; i--)
{
suf[i] = suf[i + 1];
if (i <= n - 1 && i >= 2 && i % 2 == 1)
suf[i] += costAt[i];
}
if (n % 2 == 1)
return pre[n - 1]; // 奇数 n:唯一方案就是全部偶数位置
// 偶数 n:取「偶数位置的前 j 个」与「奇数位置的后 k-j 个」,一共恰好 k 个
int k = n / 2 - 1;
long long ans = -1;
for (int j = 0; j <= k; j++)
{
long long total = pre[2 * j] + suf[2 * j + 3];
if (ans < 0 || total < ans)
ans = total;
}
return ans;
}
int main()
{
// 按题面要求使用文件 IO(见 docs/contest-conventions.md §2);
// 输出用 "wb",保证在 Windows 上本地跑也不会写出 CRLF
freopen("city.in", "r", stdin);
freopen("city.out", "wb", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
int t;
cin >> t;
while (t--)
{
cin >> n;
for (int i = 1; i <= n; i++)
cin >> h[i];
cout << solve() << "\n";
}
return 0;
}
子任务说明
| 子任务 | 分值 | 说明 |
|---|---|---|
| 1 | 30 | 且 :可以直接枚举所有两两不相邻的位置集合 |
| 2 | 为奇数且 :最优位置集合唯一,就是 | |
| 3 | 40 | 无额外限制:要处理 为偶数的"中途切换一次奇偶"的情形 |
一二数组 · 题解
1. 思路
数组里只有 与 ,记当前总和为 ,当前所有取值为 的位置里最左的是 、最右的是 。
对询问 :
-
:显然不可能,
NO; -
与 奇偶性相同:一定可以。因为把任意一个和为 的区间去掉它左边或右边的一个 , 就得到和为 的区间;反复这样能把和一步步减到 ( 与 同奇偶、且 );
-
与 奇偶性不同:必须丢掉"奇数个 "。丢得越少剩下的和越大,所以只需看两种最省的丢法:
- 丢掉前缀 ,剩下的后缀和为 ;
- 丢掉后缀 ,剩下的前缀和为 。
只要 不超过这两者中较大的那个,并且奇偶性与它相同(这两个剩下的和的奇偶性都恰好与 相反), 就
YES;否则NO。若数组里没有 ,则只能凑出偶数,直接NO。
用 set 维护所有 的位置即可在 内回答、修改同样是 。
2. 复杂度
每个测试用例 ,所有测试用例合计 ;空间 。
3. 实现要点
- 文件 IO:
ones.in/ones.out,输出用"wb"。 - 修改操作要同步更新总和 与
set(先把旧的 位置删掉,再按新值插入)。 - 输出必须与样例一致地写 大写
YES/NO(评测逐字符比较)。 - 多组数据:每组的
set与总和都要重新初始化。
4. 参考程序(与 src/std.cpp 一致)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100000; // 单个测试用例的数组长度上限
int n, q;
int a[MAXN + 5]; // a[1..n]:数组,每个元素是 1 或 2
int sumAll; // 当前数组的元素之和
set<int> posOne; // 当前所有取值为 1 的位置(下标从 1 开始)
// 当前数组里是否存在元素和恰好为 s 的连续子数组
bool canMake(int s)
{
if (s > sumAll)
return false;
if ((sumAll - s) % 2 == 0) // 与总和同奇偶:一定能凑出来
return true;
if (posOne.empty())
return false; // 全是 2:只能凑出偶数
int first = *posOne.begin(); // 最左边那个 1 的位置
int last = *posOne.rbegin(); // 最右边那个 1 的位置
// 奇偶不同:必须丢掉一段含"奇数个 1"的前缀或后缀。丢得越少剩下的和越大,
// 所以只需比较"丢掉前缀 [1, first]"(和 2*first-1)与"丢掉后缀 [last, n]"
// (和 2*(n-last)+1)这两种最省的丢法。
long long drop = min(2LL * first - 1, 2LL * (n - last) + 1);
return s <= sumAll - drop;
}
int main()
{
// 文件 IO:从 ones.in 读入、输出到 ones.out(wb 保证不写出 CRLF)
freopen("ones.in", "r", stdin);
freopen("ones.out", "wb", stdout);
int t;
scanf("%d", &t);
while (t--)
{
scanf("%d%d", &n, &q);
sumAll = 0;
posOne.clear();
for (int i = 1; i <= n; i++)
{
scanf("%d", &a[i]);
sumAll += a[i];
if (a[i] == 1)
posOne.insert(i);
}
for (int k = 1; k <= q; k++)
{
int op;
scanf("%d", &op);
if (op == 1)
{
int s;
scanf("%d", &s);
printf("%s\n", canMake(s) ? "YES" : "NO");
}
else
{
int i, v;
scanf("%d%d", &i, &v);
if (a[i] != v)
{
sumAll += v - a[i]; // 1 与 2 之间只会差 1
if (v == 1)
posOne.insert(i);
else
posOne.erase(i);
a[i] = v;
}
}
}
}
return 0;
}
5. 子任务说明
| 子任务 | 测试点 | 限制 | 想放过的写法 |
|---|---|---|---|
| 1 | 1 ~ 6 | 每次询问枚举所有子数组() | |
| 2 | 7 ~ 12 | 没有修改操作 | 静态数组:前缀和 + 双指针/前缀集合,不用管修改 |
| 3 | 13 ~ 20 | 无额外限制, 与 都卡满 | 正解;必须 |
生长之树 题解
题意要点
树一开始只有 1 号点,每个点上的数是 。 次操作:
1 v:给当前大小为 的树添加一个新点,编号 ,它上面的数是 ;2 v x:给 的子树( 自己 + 操作执行时已经存在的后代)里每个点加上 。
容易被读错、但样例 1 第一组数据明确排除了的一点:新点上的数是 ,它不会继承它出现之前 那些"给祖先的子树加值"的操作;反过来,一次操作二只影响它执行时已经存在的点,之后才出现的点不算。 下面这份样例的推算可以验证这一点:
| 操作 | 号点 | 号点 | 号点 | 号点 | 号点 |
|---|---|---|---|---|---|
| 初始 | 0 | — | |||
2 1 3 |
3 | ||||
1 1 |
0 | ||||
2 2 1 |
1 | ||||
1 1 |
0 | ||||
2 3 2 |
2 | ||||
1 3 |
0 | ||||
2 1 4 |
7 | 5 | 6 | 4 | |
1 3 |
0 | ||||
2 3 2 |
8 | 6 | 2 | ||
最后一行就是题面里的 7 5 8 6 2。注意第 7 步 2 1 4 并没有把第 8 步才出现的 号点算进去
(如果算进去, 号点会变成 ,与样例不符)。
每个点的最终值是什么
设点 是第 次操作一新建出来的( 表示 是根)。 的最终值等于
$$\sum_{\substack{\text{操作 } j \text{ 是操作二}\\ \text{且 } u_j \text{ 是 } v \text{ 的祖先或 } v \text{ 自己}\\ \text{且 } t_j < t_v}} x_j ,$$其中 是操作 的时刻、 是 出现的时刻。也就是说:只看那些"在 出现之前"执行、 并且作用在 的祖先上的操作二。子树的形状一旦确定就不会变( 的后代只可能是之后才出现的点, 而它们不影响 自己的值),所以" 是 的祖先"用最终树判断就没问题, 需要在线判断的只有""这一件事。
做法
子任务 1():直接模拟
每次操作二就着当前的树真的遍历一遍 的子树并加 。单次代价是子树大小, 小的时候总代价 最多约 ,随便过。复杂度 。
子任务 2(所有加点都在所有加值之前):建完树做一次 dfs
此时树在第一次操作二之前就已经定型:设点 上被加了 (把所有操作二的 按 累加), 那么从根往下走一遍,用 带着"根到当前点这条链上累加到的和":
一遍 dfs 求出所有点的答案,。
满分做法:Euler 序 + 树状数组(按输入顺序扫一遍)
- 先把最终的树建出来:第 次操作一新建的点编号就是 ,所以按操作顺序读到
1 v时 直接把新点挂到 上即可(这个编号恰好就是它在最终树里的编号)。 - 对最终树求一遍 dfs 序。子树在 dfs 序里是一段连续的区间 。
- 按输入顺序再扫一遍操作,用一个支持"区间加、单点求值"的树状数组(差分 + 前缀和)维护
"当前每个位置上累计被加了多少":
2 v x:给 整体加 ;1 v(新点是 ):此时树状数组里 处的单点值,正是"在 出现之前执行过、 并且覆盖到 的那些操作二"之和。因为 出现之前它是空的(值为 ),这部分值必须扣掉。 记start[u]为这个单点值即可。
- 最后每个点的答案是
bit.query(in_v) - start[v]。
第 3 步为什么要"再扫一遍":建树时已经把最终树定下来了,但树状数组上的加值必须按真实时间顺序发生, 这样新点出现时才能把它"出现之前"已经累计的部分记下来并从最终结果里扣掉。
复杂度:建树 + dfs + 次树状数组操作, 每组,空间 。
也可以用另一种等价写法:把操作倒着扫一遍。倒扫时"操作二给 的子树区间加 "、 "操作一新建的点 在这一刻单点求值"就正好得到 的最终答案(倒着扫时,先处理的是更晚的操作, 正好只把 出现之后的加值算进去;根最后再查一次)。两份实现都在下面复核过。
实现要点
- 不能写递归 dfs: 可以取到 ,数据里有一条 个点的链,
递归深度会爆栈。标程用显式栈(
std::vector<int>当栈,用负号标记"出栈")。 - 所有测试用例的 之和不超过 ,所以每组重建数组的开销按组清零即可; 标程用全局数组 + 每组重置用到的部分。
- 答案会超过 32 位:一个点最多被 次操作各加 ,达到 ,
必须用
long long。 - 输入规模到 行、 个整数,加
ios::sync_with_stdio(false); cin.tie(0);。
参考实现
70 分代码,把暴力枚举用数据结构优化就能满分了。
#include <bits/stdc++.h>
#define int long long
using namespace std;
struct Ev
{
// 1:对 v 增加子节点,增加的点编号是 x
// 2:对 v 子树增加 x
int op, v, x;
};
int q;
Ev ev[500000 + 5];
vector<int> e[500000 + 1 + 5];
int dfs_now, dfn[500000 + 5], siz[500000 + 5];
void dfs(int u)
{
dfn[u] = ++dfs_now;
siz[u] = 1;
for (int v : e[u])
{
dfs(v);
siz[u] += siz[v];
}
}
long long start[500000 + 5]; // 存每个点加入的时候,他已经达到的大小
long long w[500000 + 5]; // 每个点的大小
void work()
{
cin >> q;
for (int i = 1; i <= q; i++)
{
cin >> ev[i].op >> ev[i].v;
if (ev[i].op == 2)
cin >> ev[i].x;
}
int tot = 1;
for (int i = 1; i <= q; i++)
{
if (ev[i].op == 1)
{
tot++;
e[ev[i].v].push_back(tot);
ev[i].x = tot;
}
}
dfs_now = 0;
dfs(1); // dfs 序处理完
for (int i = 1; i <= q; i++)
{
if (ev[i].op == 1)
{
int now = ev[i].x; // 新加的点
start[dfn[now]] = w[dfn[now]];
// cout << ev[i].x << "#" << w[ev[i].x] << "\n";
}
if (ev[i].op == 2)
{
// 子树 ev[i].v 对应的区间暴力修改
int now = ev[i].v;
for (int j = dfn[now];
j <= dfn[now] + siz[now] - 1;
j++)
w[j] += ev[i].x;
// cout << ev[i].v << "@" << ev[i].x << "\n";
// for(int i=1;i<=tot;i++)
// cout<<w[i]<<",";
// cout<<"\n";
}
}
for (int i = 1; i <= tot; i++)
cout << w[dfn[i]] - start[dfn[i]] << " ";
cout << "\n";
for (int i = 1; i <= tot; i++)
e[i].clear(), w[i] = 0;
}
signed main()
{
freopen("grow.in", "r", stdin);
freopen("grow.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
int t;
cin >> t;
while (t--)
work();
return 0;
}
子任务说明
| 子任务 | 分值 | 说明 |
|---|---|---|
| 1 | 30 | :每次操作二真的遍历一遍子树, 也能过 |
| 2 | 所有加点操作都在加值操作之前:树在第一次加值前就定型,建完树做一次 dfs 即可 | |
| 3 | 40 | 无额外限制, 卡满 ,含链、菊花、随机父亲树与加点/加值交错的点 |
异或树 题解
思路
以 为根,记 为根到 (含两端)路径上所有数的异或和。
对一条简单路径 ,设 。 把 与 两段上的点各算了一次,而 一次也没算, 所以
$$b_u \oplus b_v = \bigl(\text{路径 } u\to v \text{ 上所有数的异或和}\bigr) \oplus a_w .$$于是
$$u \to v \text{ 的权为 } 0 \iff b_u \oplus b_v = a_w .$$上式对 (路径的端点之一就是 )同样成立,此时变成 。
为什么"能改上面的点就改上面的点"不吃亏。 把 改成 后, 子树里每个点的 值都同时异或上 。于是:
- 两端都在 的同一个儿子子树内、 严格低于 的路径,两个端点各被异或一次 ,,权不变;
- 所有 恰好是 的路径一定经过 ,改 可以一次把它们全部破坏: 例如把 换成 这样互不相同的"高位" 的幂,任何含 的路径权里都会 带上这个高位(若干个不同高位 的幂异或起来仍不为 ,低位部分都 ), 于是权必不为 。
所以自底向上贪心:某个点 上只要还存在" 为 "的权为 的路径, 就操作 一次(而不是去操作更低处的点),这样不会比最优解差。
如何快速判断。 自底向上维护集合 : 子树内还没有被切断的点的 值。 处理 时:
- 把各个儿子的集合启发式合并(小集合并进大集合,每个元素最多被搬动 次);
- 合并过程中,对每个即将插入的元素 ,检查 是否已经在别的儿子的集合里 —— 存在就说明有一条 、权为 的路径;
- 再单独检查 是否在合并后的集合里(对应路径的一个端点是 自己);
- 若发现这样的路径,答案加一,并清空这个集合: 被改过之后, 子树里任何走到 子树外的路径都经过 ,已经被这次操作破坏,这些 值不需要(也不应该)再往上传递;
- 否则把 也插入集合,连同集合一起交给父亲。
注意第 2 步只能比较不同儿子里的点:同一个儿子内部的两个点 更低, 对应的路径在这一步并不归 管。因此实现时要把"比较"和"插入"分成两遍做。
算法与复杂度
- 时间:启发式合并下每个元素最多被搬动 次,哈希表插入/查询均摊 ,
总期望 (把哈希表换成
std::set是 )。 实测(含读入与进程启动):标程在每个满规模测试点上都不超过 0.35s, 换成std::set的另一份实现也不超过 0.7s,时限 3s 有充足余量。 - 空间:。
- 注意: 可以是一条 个点的链,不能写递归的 dfs(会爆栈), 标程用 bfs 序的逆序处理。
实现要点
- 集合的所有权:每个点用一个
unordered_set<int>*,小集合合并进大集合后立刻delete,否则内存会被反复复制撑大。 - 比较与插入分两遍:先扫一遍小集合做冲突检查,再整体插入;若边查边插, 同一个儿子内部的两个点会被误判成冲突,答案会偏大。
- 别忘了清空:发现冲突后必须把集合作废(
alive[v] = NULL), 否则子树里的 值继续往上走,祖先会重复计数。 - 两个端点是同一点的情况:检查 时要先合完所有儿子再查。
- (每个 ),所以一切用
int就够,答案 。 - 读入量 个点、 条边,加
ios::sync_with_stdio(false); cin.tie(0);。
参考实现
与 problems/d3s3/src/std.cpp 完全一致(文件 IO:tree.in / tree.out)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200000;
int n;
int a[MAXN + 5];
vector<int> e[MAXN + 5];
int sum[MAXN + 5]; // 根节点到每个点的点权异或和
set<int> se[MAXN + 5]; // 每个子树还要处理的点/当前树之前的点
int ans;
void dfs(int u, int fa)
{
se[u].insert(sum[u]); // 直接存点的编号
bool flag = false;
for (int v : e[u])
{
if (v == fa)
continue;
sum[v] = sum[u] ^ a[v];
dfs(v, u);
// 启发式合并,永远保持 e[u] 是较大的
if (se[v].size() > se[u].size())
swap(se[u], se[v]);
// 检查 v 里面哪些点会构成异或和为 0 的路径
for (int now : se[v])
if (se[u].find(now ^ a[u]) != se[u].end())
flag = true; // 找到了
for (int now : se[v])
se[u].insert(now);
}
if (flag)
ans++, se[u].clear();
}
int main()
{
freopen("tree.in", "r", stdin);
freopen("tree.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++)
cin >> a[i];
for (int i = 1; i <= n - 1; i++)
{
int u, v;
cin >> u >> v;
e[u].push_back(v);
e[v].push_back(u);
}
ans = 0;
sum[1] = a[1];
dfs(1, 0);
cout << ans;
return 0;
}
子任务说明
| 子任务 | 分值 | 说明 |
|---|---|---|
| 1 | 30 | :可以枚举"要改哪些点"的所有子集做判定 |
| 2 | :不做启发式合并、每个点老老实实合并儿子的集合也能过 | |
| 3 | 40 | :必须启发式合并(链、深树的数据会卡掉 的合并) |