再谈铲雪
stripe_python · · 算法·理论
本文将采用启发式的题解模式,引导读者一步步自己想出做法。
Fun Fact: 这篇文章编写时,BJ 正在下雪,并且 rdfz 的一些同学严肃进行了铲雪,铲出了若干非平凡图案(
1. 铲雪模型
“铲雪”这个名字来源于一场校内模拟赛。下面是基本的铲雪模型:
给你无向图
G ,点带权,一次操作将一个特定子图G' 上所有点的点权减去1 ,问元素全变成0 的最少操作数。
在铲雪题中,
2. 连续铲雪
2.1 链上铲雪
::::info[提示 1]
考虑一个“顺带”的思想,用较大的
::::info[提示 2] 考虑差分转化。尝试构造一个方案,使得最优解可以取到。 ::::
:::::success[题解]
我们记
可以证明答案就是所有正差分之和,即
::::info[感性理解]{open}
根据提示 1,用较大的
于是较小的
无论什么策略,必然要花费
::::info[严格证明] 用差分刻画一下这个操作。
记
- 对区间
[l,r] 的操作为d_l \gets d_l-1,d_{r+1} \gets d_{r+1} +1 。 - 目标状态为
\forall i \in [1,n],d_i=0 。
:::info[必要性]{open}
一次操作至多让一个大于
:::info[充分性]{open} 只需给出一个最优解的构造。先给出两个观察:
-
观察 1:若
d_i>0 ,则a_i>0 。
证明:即a_i>a_{i-1} \ge 0 。 -
观察 2:
\forall d_i>0,\exists j \in (i,n],d_j<0 。 证明:显然\sum d = 0 。且对于d_i>0 ,\sum \limits_{j \in [1,i)} d_j = d_i>0 ,故\sum \limits_{j \in (i,n]} d_j < 0 ,即\exists j \in (i,n],d_j<0 。
可以按如下方法得到最优解:
- 每次选择一个
d_i>0 的i 作为l 。根据观察 2,总能找到满足j>i,d_j<0 的最小的j 作为r 。根据观察 1 易得该操作合法。
不难发现操作次数就是
2.2 环上铲雪
::::info[提示 1] 考虑答案的下界。 ::::
::::info[提示 2] 环和序列很像,可以参考上一题。 ::::
::::info[提示 3] 序列中的最大值也对答案下界有贡献。 ::::
::::info[提示 4] 可能你得到了一个比较离谱的结论,能否尝试证明? ::::
:::::success[题解]
我们记
可以发现答案有两个下界,即
答案就是
::::info[严格证明]
:::info[必要性]{open}
一次操作至多让
:::info[充分性]{open} 只需给出一个最优解的构造。下分三种情况讨论:
- 当
M=D 时,只需使用上一题的方法即可得到最优解。
对于
- 当
M>D 时,有引理:环中没有0 。故直接整体减去1 即可令M \gets M-1 ,重复操作至M=D 。
引理的证明:显然D \ge \max \limits_{i=1}^n a_i-\min \limits_{i=1}^n a_i ,当环中有0 时得D \ge M ,与前提矛盾。 - 当
M<D 时,选择一个全为最大值的极长区间减去1 ,则D \gets D-1 ,而M 至多减去1 ,重复操作至M=D 。 ::: :::: :::::
2.3 树上铲雪
我们会先给出两个前置题目。
2.3.1 链上双点铲雪
::::info[提示 1] 去看环上铲雪。你是否得到了一些必要条件? ::::
::::info[提示 2] 能否通过构造说明这些条件是充分的? ::::
:::::success[题解]
我们记
设
::::info[严格证明]
:::info[必要性]{open}
首先
若
由此证明
:::info[充分性]{open}
进行归纳构造。首先容易验证
仿照环上铲雪的证明,只需重复操作至
2.3.2 树上双叶铲雪
::::info[提示 1] 先找到一个根,自底向上求一些信息。 ::::
::::info[提示 2] 如果我们得到了儿子到父亲的一条路径,这条路径有几种选择? ::::
::::info[提示 3] 去看链上双点铲雪的结论。尝试用数学语言刻画合法的充要条件。 ::::
:::::success[题解]
特判
定义“线头”为一条可以从儿子到父亲的路径。
记
线头有两种选择:继续向上延伸,或者拐向另一个儿子(我们称这是两个线头合并)。二者的数目分别为
移项得
直接自底向上统计
最终还要求根节点没有线头,即
有一些处理细节,给出代码。
:::info[代码]
constexpr 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 真·树上铲雪
::::info[提示 1] 拓展一下树上双叶铲雪的线头思想。 ::::
::::info[提示 2] 考虑刻画每个节点的线头合并次数。 ::::
:::::success[题解]
仍然记
设
- 显然
c_u \le a_u 。 -
- 线头合并需要至少两条,因此
c_u \le \lfloor \dfrac{1}{2} \sum f_v \rfloor 。 - 考虑木桶效应,
\max f_v 也会造成无法继续合并,于是c_u \le \sum f_v-\max f_v 。
综合以上四条我们得到:
得到
::::info[代码]
int 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;
}
::::
::::info[感性理解]{open} 把线头合并变成延伸的话,可以在当前位置减少一次操作,但会支付未来的两次操作,故一定不优。必要性已经证明。 ::::
::::info[严格证明] 必要性其实在分析的过程中已经证明了,我们需要说明充分性。
:::info[充分性]{open}
考虑调整法,即说明
设
而对于
因此
3. 跳跃铲雪
3.1 链上跳跃铲雪
::::info[提示 1] 每个位置应该被一些“直线”和“跳线”覆盖。 ::::
::::info[提示 2] 直线是否一定优于跳线? ::::
::::info[提示 3]
按
::::info[提示 4] 如果你不知道当前位置填什么,考虑延迟决策。 ::::
::::info[提示 5] 不要想的过于复杂,考虑一遍扫描出答案。 ::::
::::info[提示 6] 维护一个标记表示可以无代价决策几条线。 ::::
:::::success[题解] 称第一类操作为“直线”,二三类操作为“跳线”。
我们声称,一段从
- 若
a_i>0,a_{i+1}>0 ,从i 开始延伸一条直线。 - 若
a_i>0,a_{i+1}=0 ,从i 开始延伸一条跳线。 - 若
a_i=0 ,延迟决策。
我们在扫描的过程中记录
记录下
::::info[代码] 写法来自第一篇题解。
#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';
}
::::
::::info[声称的证明]
设极长区间为
考虑调整,显然不可向
4. 循环铲雪
给出
m ,一次操作将点权变为(a_i\pm 1) \bmod m 。
4.1 链上循环铲雪
一个区间询问的版本:P4846。
4.1.1 全局询问
:::info[提示 1] 考虑差分转化。 :::
:::info[提示 2] 考虑目标状态的形态。 :::
:::info[提示 3] 取模是难刻画的,能否提前决策是否取模? :::
:::info[提示 4] 考虑每个数取值对答案的影响,思考如何确定影响。 :::
:::::success[题解]
做一个差分转化,设
那么一次操作就是选择两个
又因为
考虑链上铲雪的结论,
取模这个东西难以处理。事实上,我们可以提前决策每个数的最终取值,即无代价地选择
对于正差分
注意到
::::info[代码]
long 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 区间询问
::::info[提示 1] 考虑上面查询时,我们需要什么。能否用数据结构维护? ::::
::::info[提示 2] 考察答案研究的对象,思考它的性质,或者打表观察。 ::::
:::::success[题解]
考虑我们实际上干了一个什么事情,可以发现我们需要的是 p1、p2 的前
可以用可持久化权值线段树维护一个区间的前
考虑省去枚举分界点的部分。对 p1[i] + p2[i] 打表,或者感性理解一下可以知道这是一个单谷函数,可以三分求出
复杂度
::::info[单谷的证明]
我们设
设负的
同理设正的
令
其差分
由
4.1.3 一道联考题
给出
q 次操作,每次操作对a 中不小于y 的数加上x ,然后查询以m+x 为模数的链上循环铲雪。操作之间独立。
::::info[提示 1] 仍然考虑套用区间询问的做法。 ::::
::::info[提示 2] 你可能需要讨论正负差分的影响。是否可以统一为一种? ::::
::::info[提示 3]
考虑离线,则每个
:::::success[题解] 比较可惜的是笔者场上 All in 这个题,最后 48pts 遗憾离场。
仍然可以三分答案,考虑维护 p1、p2。
笔者的赛时做法是讨论
实际上考虑循环铲雪的本质,每个数在一个以
考虑将询问离线。则
本题暂未找到单
5. 总结
铲雪模型是一种比较灵活的思维题。一般要贪心或者推结论。
在证明中,一般考虑分为两步,先证必要再证充分,必要性一般是好证的,充分性需要有比较厉害的构造水平。
顺带一提,这种题的通用做法是线性规划。由于作者太菜,本文只介绍了贪心的思路。
6. 拓展
铲雪题远远没有被出完!
即使是本文提到的铲雪类型,也应当有链 / 环 / 树 上 连续 / 跳跃 / 循环 / 跳跃循环 铲雪共
然而本文中提及的本质不同的原题只有
然而截止本文完成前,作者并不会任何其他类型的铲雪题。如果有大手子会了其中任何一种,欢迎在评论区交流或者搬到模拟赛里狙击别人(
所以基环树上铲雪怎么做?
笔者声称,先对树铲雪再对环铲雪可以得到最优答案。
具体地,对每棵树进行一次铲雪求出线头数目,则问题转化为一个带权环上铲雪,每次可以从环上一个点免费开启一条线,还可以以负代价合并两条线。
但是本人太菜了,做不出来这个东西。希望有大手子将其做完 / 证伪 / 证明 NPC。
单点清空的铲雪怎么做?
这是大手子 zzh 老师提出的。
考虑给铲雪增加一个操作:选择一个位置使之变为
感觉最简单的序列清空铲雪都不会做了。