专栏文章
再谈铲雪
算法·理论参与者 24已保存评论 31
文章操作
快速查看文章及其快照的属性,并进行相关操作。
- 当前评论
- 31 条
- 当前快照
- 2 份
- 快照标识符
- @mjbosy8p
- 此快照首次捕获于
- 2025/12/19 01:01 2 个月前
- 此快照最后确认于
- 2026/02/19 01:28 8 小时前
本文将采用启发式的题解模式,引导读者一步步自己想出做法。
Fun Fact: 这篇文章编写时,BJ 正在下雪,并且 rdfz 的一些同学严肃进行了铲雪,铲出了若干非平凡图案(
1. 铲雪模型
“铲雪”这个名字来源于一场校内模拟赛。下面是基本的铲雪模型:
给你无向图 ,点带权,一次操作将一个特定子图 上所有点的点权减去 ,问元素全变成 的最少操作数。
在铲雪题中, 以及操作形式都会有变化。
2. 连续铲雪
为一条简单路径。
2.1 链上铲雪
提示 1
考虑一个“顺带”的思想,用较大的 的“顺带”铲掉较小的 。
提示 2
考虑差分转化。尝试构造一个方案,使得最优解可以取到。
题解
我们记 为序列长度, 为序列,并且约定 。
可以证明答案就是所有正差分之和,即
感性理解
根据提示 1,用较大的 的“顺带”铲掉较小的 。
于是较小的 会被铲没,较大的 减小 的高度。
无论什么策略,必然要花费 的代价(因为这个位置铲没以后,接下来的区间不能跨过 )。而 的代价是免费的。因此贪心是正确的。
严格证明
用差分刻画一下这个操作。
记 。
- 对区间 的操作为 。
- 目标状态为 。
必要性
一次操作至多让一个大于 的 减 ,故最优解不小于 。
充分性
只需给出一个最优解的构造。先给出两个观察:
-
观察 1:若 ,则 。
证明:即 。 -
观察 2:。 证明:显然 。且对于 ,,故 ,即 。
可以按如下方法得到最优解:
- 每次选择一个 的 作为 。根据观察 2,总能找到满足 的最小的 作为 。根据观察 1 易得该操作合法。
不难发现操作次数就是 。
2.2 环上铲雪
提示 1
考虑答案的下界。
提示 2
环和序列很像,可以参考上一题。
提示 3
序列中的最大值也对答案下界有贡献。
提示 4
可能你得到了一个比较离谱的结论,能否尝试证明?
题解
我们记 为环长, 为序列。
可以发现答案有两个下界,即 和 。 其实就是上一题的答案变成环上差分。
答案就是 。
严格证明
必要性
一次操作至多让 减少 ,故最优解不小于 。
充分性
只需给出一个最优解的构造。下分三种情况讨论:
- 当 时,只需使用上一题的方法即可得到最优解。
对于 的情况,考虑规约到 。
- 当 时,有引理:环中没有 。故直接整体减去 即可令 ,重复操作至 。
引理的证明:显然 ,当环中有 时得 ,与前提矛盾。 - 当 时,选择一个全为最大值的极长区间减去 ,则 ,而 至多减去 ,重复操作至 。
2.3 树上铲雪
我们会先给出两个前置题目。
2.3.1 链上双点铲雪
为链, 为任意两点。只需判断可行性而无需最优化。
提示 1
去看环上铲雪。你是否得到了一些必要条件?
提示 2
能否通过构造说明这些条件是充分的?
题解
我们记 为序列长度, 为序列。
设 。当且仅当 为偶数且 时可行。
严格证明
必要性
首先 为奇数显然不可行。
若 ,则最大值无法被其他数字消去。
由此证明 为偶数和 为可行的必要条件。
充分性
进行归纳构造。首先容易验证 可行。
仿照环上铲雪的证明,只需重复操作至 在序列中。由于 ,一定可以构造出这种情况,于是忽略 后得到了若干规模在 中的子问题,归纳即可。
2.3.2 树上双叶铲雪
提示 1
先找到一个根,自底向上求一些信息。
提示 2
如果我们得到了儿子到父亲的一条路径,这条路径有几种选择?
提示 3
去看链上双点铲雪的结论。尝试用数学语言刻画合法的充要条件。
题解
特判 。然后选择一个度数不为 的点为根。
定义“线头”为一条可以从儿子到父亲的路径。
记 为 作儿子的线头数目,然后设 ,即 作父亲的线头数目。
线头有两种选择:继续向上延伸,或者拐向另一个儿子(我们称这是两个线头合并)。二者的数目分别为 和 。容易在 处列出方程:
移项得
直接自底向上统计 即可。考虑可行的必要条件:
- 。这是显然的。
- 。这是两条线头合并的要求,可以发现这等价于链上双点铲雪。
最终还要求根节点没有线头,即 。充分性照搬链上双点铲雪的构造即可。
有一些处理细节,给出代码。
代码
CPPconstexpr int N = 1e5 + 86;
int tot = 0, head[N];
struct Edge {int next, to;} edge[N << 1];
void add_edge(int u, int v) {edge[++tot].next = head[u], edge[tot].to = v, head[u] = tot;}
int n, a[N], d[N];
long long f[N], s[N];
bool dfs(int u, int fa) {
if (d[u] == 1) return f[u] = a[u], true;
long long mx = 0;
for (int j = head[u]; j != 0; j = edge[j].next) {
int v = edge[j].to;
if (v == fa) continue;
if (!dfs(v, u)) return false;
s[u] += f[v], mx = max(mx, f[v]);
}
f[u] = 2 * a[u] - s[u];
return 0 <= f[u] && f[u] <= a[u] && mx <= a[u];
}
void _main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
if (n == 2) return cout << (a[1] == a[2] ? "YES" : "NO"), void();
for (int i = 1, u, v; i < n; i++) cin >> u >> v, add_edge(u, v), add_edge(v, u), d[u]++, d[v]++;
int rt = 1;
for (int i = 1; i <= n; i++) if (d[i] > 1) rt = i;
cout << (dfs(rt, -1) && f[rt] == 0 ? "YES" : "NO");
}
2.3.3 真·树上铲雪
提示 1
拓展一下树上双叶铲雪的线头思想。
提示 2
考虑刻画每个节点的线头合并次数。
题解
仍然记 为 作儿子的线头数目,且 。但是由于不要求路径端点为叶子,因此它不满足树上双叶铲雪的递推式。
设 为 节点处线头合并次数,则它对答案的贡献为 。结论为 取到上界时,答案最小。我们考虑求出 。
- 显然 。
- 于来源所有儿子,因此其不能超过儿子的贡献,也即 。
- 线头合并需要至少两条,因此 。
- 考虑木桶效应, 也会造成无法继续合并,于是 。
综合以上四条我们得到:
得到 以后, 自底向上计算即可。给出代码。
代码
CPPint opt, n, seed, a[N], u[N], v[N];
int tot = 0, head[N];
struct Edge {
int next, to;
} edge[N << 1];
inline void add_edge(int u, int v) {
edge[++tot].next = head[u], edge[tot].to = v, head[u] = tot;
}
long long f[N], s[N], c[N], ans;
void dfs(int u, int fa) {
long long mx = 0;
for (int j = head[u]; j != 0; j = edge[j].next) {
int v = edge[j].to;
if (v == fa) continue;
dfs(v, u), s[u] += f[v], mx = max(mx, f[v]);
}
c[u] = max(0LL, min<long long>({a[u], s[u] - a[u], s[u] / 2, s[u] - mx}));
ans += max(0LL, a[u] - s[u] + c[u]) - c[u];
f[u] = a[u] - c[u];
}
void _main() {
cin >> opt;
if (opt == 1) {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i < n; i++) cin >> u[0] >> v[0], add_edge(u[0], v[0]), add_edge(v[0], u[0]);
} else {
cin >> seed >> n;
Generate::n = n, Generate::seed = seed, Generate::RS(a, u, v);
for (int i = 1; i < n; i++) add_edge(u[i], v[i]), add_edge(v[i], u[i]);
}
dfs(1, -1);
cout << ans;
}
感性理解
把线头合并变成延伸的话,可以在当前位置减少一次操作,但会支付未来的两次操作,故一定不优。必要性已经证明。
严格证明
必要性其实在分析的过程中已经证明了,我们需要说明充分性。
充分性
考虑调整法,即说明 减小不优。
设 。当 时,根据递推式知 ,无法调整。
而对于 , 时 ,答案变化量为 。
因此 只会使得答案变大。
3. 跳跃铲雪
为一条简单路径,或者一段简单路径上挖去所有奇数下标点或偶数下标点。
3.1 链上跳跃铲雪
提示 1
每个位置应该被一些“直线”和“跳线”覆盖。
提示 2
直线是否一定优于跳线?
提示 3
按 与 的关系分类讨论,决策当前位置的覆盖线。
提示 4
如果你不知道当前位置填什么,考虑延迟决策。
提示 5
不要想的过于复杂,考虑一遍扫描出答案。
提示 6
维护一个标记表示可以无代价决策几条线。
题解
称第一类操作为“直线”,二三类操作为“跳线”。
我们声称,一段从 开始的长度 的非 极长区间用直线覆盖不劣。于是可以按 分类讨论:
- 若 ,从 开始延伸一条直线。
- 若 ,从 开始延伸一条跳线。
- 若 ,延迟决策。
我们在扫描的过程中记录 表示延伸到当前位置的直线与跳线的数目。若 ,则 。否则有 条线无法延伸。
记录下 并延迟决策,则考虑新位置 ,该位置有 条无代价的线。在讨论对答案的贡献时处理一下,一遍扫描即可出答案。
代码
写法来自第一篇题解。
CPP#define int long long
const int N = 1e5 + 5;
int n, a[N];
void _main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
int res = 0, A = 0, B = 0, nB = 0, W = 0; // nB 维护不同奇偶性的跳线数目
for (int i = 2; i <= n + 1; swap(B, nB), i++) {
if (a[i] < A + B) {
W = A + B - a[i];
if (W > B) A -= W - B, W -= W - B;
if (W > A) B -= W - A, W -= W - A;
A -= W, B -= W, a[i] -= W;
}
a[i] -= A + B;
int C = min(a[i - 1], a[i]); // 新增直线数目
res += C, a[i - 1] -= C, a[i] -= C, A += C; // 直线
res += a[i - 1], nB += a[i - 1], a[i - 1] = 0; // 跳线
if (W) a[i] += W, res -= W, W = 0; // 标记
} cout << res << '\n';
}
声称的证明
设极长区间为 ,其满足 ,且 。
考虑调整,显然不可向 连出直线,合法方案只可能由 开始延伸跳线。则此时代价至少为 。而原方案的代价恰好为 ,故调整不优。
4. 循环铲雪
给出 ,一次操作将点权变为 。
4.1 链上循环铲雪
为链, 为简单路径,操作变为选择 然后将区间加上 对 取模。
一个区间询问的版本:P4846。
4.1.1 全局询问
提示 1
考虑差分转化。
提示 2
考虑目标状态的形态。
提示 3
取模是难刻画的,能否提前决策是否取模?
提示 4
考虑每个数取值对答案的影响,思考如何确定影响。
题解
做一个差分转化,设 并且约定 。
那么一次操作就是选择两个 ,使得 。注意到目标态即 。显然 不优,所以 。
又因为 ,所以我们得到目标状态的刻画:,且 与 成对出现。
考虑链上铲雪的结论, 这个东西不好计算,而根据 ,故正差分与负差分绝对值之和相等,可以改写为 。
取模这个东西难以处理。事实上,我们可以提前决策每个数的最终取值,即无代价地选择 ,令 ,目标态即 。
对于正差分 ,其减去 后的答案变化量为 。对于负差分 ,其加上 后的答案变化量仍为 。
注意到 成对出现,将答案变化量处理出来以后排序,做一个前缀和然后枚举分界点即可,整个做法的正确性显然。
代码
CPPlong long solve(int m) {
long long sum = 0;
for (int i = 1; i <= n + 1; i++) d[i] = a[i] - a[i - 1], sum += abs(d[i]);
vector<long long> p1, p2;
for (int i = 1; i <= n + 1; i++) {
if (d[i] < 0) p1.emplace_back(m + 2 * d[i]);
else p2.emplace_back(m - 2 * d[i]);
}
sort(p1.begin(), p1.end()), sort(p2.begin(), p2.end());
for (int i = 1; i < (int) p1.size(); i++) p1[i] += p1[i - 1];
for (int i = 1; i < (int) p2.size(); i++) p2[i] += p2[i - 1];
long long res = sum;
for (int i = 0; i < min<int>(p1.size(), p2.size()); i++) res = min(res, sum + p1[i] + p2[i]);
return res / 2;
}
4.1.2 区间询问
提示 1
考虑上面查询时,我们需要什么。能否用数据结构维护?
提示 2
考察答案研究的对象,思考它的性质,或者打表观察。
题解
考虑我们实际上干了一个什么事情,可以发现我们需要的是
p1、p2 的前 小数的总和。可以用可持久化权值线段树维护一个区间的前 小之和。
考虑省去枚举分界点的部分。对
p1[i] + p2[i] 打表,或者感性理解一下可以知道这是一个单谷函数,可以三分求出 。复杂度 。
单谷的证明
我们设 为满足 的 的前 大数之和, 为满足 的 的前 大数之和,然后令 。
设负的 共有 个,记为 ,满足 。对应的 ,则有 。于是 。
同理设正的 共有 个,记为 ,满足 。对应的 ,则有 。于是 。
令 ,则对于 ,有
其差分
由 单调递增, 单调递减,则序列 单调递增。因此 也单调递增。综上, 是一个凸序列。
4.1.3 一道联考题
给出 次操作,每次操作对 中不小于 的数加上 ,然后查询以 为模数的链上循环铲雪。操作之间独立。,3 秒,512 MB。
提示 1
仍然考虑套用区间询问的做法。
提示 2
你可能需要讨论正负差分的影响。是否可以统一为一种?
提示 3
考虑离线,则每个 对哪些询问有影响?
题解
比较可惜的是笔者场上 All in 这个题,最后 48pts 遗憾离场。
仍然可以三分答案,考虑维护
p1、p2。笔者的赛时做法是讨论 的四种大小关系,被出题人 lhx 大手子锐评这是“荒唐的”。
实际上考虑循环铲雪的本质,每个数在一个以 为长度的环上绕圈,那么对于负差分将其对 取模,处理一下代价就转化为正,故只需分有无影响讨论即可。
考虑将询问离线。则 对于 的询问 有影响。自然地,对 排序扫描线,询问三分即可。需要实现一个动态维护前 大的数据结构,使用权值树状数组 / 权值线段树 / 01-Trie / FHQ-Treap 均能将本题做到 。
本题暂未找到单 做法。
5. 总结
铲雪模型是一种比较灵活的思维题。一般要贪心或者推结论。
在证明中,一般考虑分为两步,先证必要再证充分,必要性一般是好证的,充分性需要有比较厉害的构造水平。
顺带一提,这种题的通用做法是线性规划。由于作者太菜,本文只介绍了贪心的思路。
6. 拓展
铲雪题远远没有被出完!
即使是本文提到的铲雪类型,也应当有链 / 环 / 树 上 连续 / 跳跃 / 循环 / 跳跃循环 铲雪共 种题,并且还可以在其他图上铲雪(基环树等),或者带上区间查询 / 子树查询 / 树链查询 / 单点修改等。
然而本文中提及的本质不同的原题只有 种,这远少于应当铲雪题应当有的规模。
然而截止本文完成前,作者并不会任何其他类型的铲雪题。如果有大手子会了其中任何一种,欢迎在评论区交流或者搬到模拟赛里狙击别人(
所以基环树上铲雪怎么做?
笔者声称,先对树铲雪再对环铲雪可以得到最优答案。
具体地,对每棵树进行一次铲雪求出线头数目,则问题转化为一个带权环上铲雪,每次可以从环上一个点免费开启一条线,还可以以负代价合并两条线。
但是本人太菜了,做不出来这个东西。希望有大手子将其做完 / 证伪 / 证明 NPC。
单点清空的铲雪怎么做?
这是大手子 zzh 老师提出的。
考虑给铲雪增加一个操作:选择一个位置使之变为 。
感觉最简单的序列清空铲雪都不会做了。
相关推荐
评论
共 31 条评论,欢迎与作者交流。
正在加载评论...