LOJ 4192 「BalticOI 2024」Jobs
因为目标是要赚钱,并且走到负数点权的点时也肯定希望钱数尽量大。
所以贪心的,只有当走进这个子树后不亏才会走进去。
但是问题在于,对于有多个可走的点的情况,走哪边更优?
于是考虑定义 \(f_u\) 代表走进 \(u\) 这个子树最小需要有多少钱才能不亏。
但是这里有点不一样的是保证 \(f_u\le 0\),当 \(s + f_u\ge 0\) 时就可以走进(这样更直观一些,可以把 \(s + f_u\) 当作走进去的最小值),所以实际上维护的是 \(\max f_u\)。
为什么不需要考虑维护赚钱的最大值之类的信息?这是因为已经保证了赚的钱肯定非负,这些信息是无用的,考虑:
假设走进 \(u\) 会赚 \(\Delta_u\),走进 \(v\) 会赚 \(\Delta_v\),那么先走 \(u\) 再走 \(v\) 经过的钱数最小值为 \(s + \min(f_u, \Delta_u + f_v)\),先走 \(v\) 再走 \(u\) 的最小值为 \(s + \min(f_v, \Delta_v + f_u)\)。
那么当 \(f_u \ge f_v\) 时,一定会选择先走 \(u\),这是因为 \(f_u\ge f_v, \Delta_u + f_v\ge f_v\),所以 \(\min(f_u, \Delta_u + f_v) > f_v = \min(f_v, \Delta_v + f_u)\)。
于是可以知道的是,每次选择的扩展的点一定是 \(f_u\) 最大的点。
那么只要 \(s + f_u\ge 0\),就可以扩展 \(u\),并且在扩展完 \(u\) 后,就可以继续去尝试扩展 \(u\) 的儿子节点了。
因为上面的过程也是一个贪心,所以可以考虑用一个堆来维护可以扩展的点集。
然后每次取出 \(u\),可以扩展就把所有 \(v\in \operatorname{son}_u\) 放进堆继续做下去。
于是接下来的问题就来到了如何求解 \(f_u\)。
考虑到对于答案的求解其实可能与 \(f_u\) 是有点类似的,但是不同之处在与求答案时是给定 \(s\) 去求解最大赚钱数,而求解 \(f_u\) 是到不亏即可,求最小的 \(s\)。
于是说,一个想法就是直接二分 \(f_u\) 的值 \(s'\),并且把 \(s'\) 以与求解答案相同的形式去求解 \(u\) 子树内最大赚钱数,最后与 \(0\) 比较,就可以做到 \(\mathcal{O}(n^2 \log^2 n)\)。
但是实质上考虑这个二分求解最大赚钱数的过程,因为只关心与 \(0\) 的大小关系,所以只要在求解最大赚钱数的过程中存在一个时刻赚钱数 \(\ge 0\) 这个 \(s'\) 就是合法的,就不用算下去了。
并且这个贪心的过程是确定了,不管带入什么 \(s'\) 求解的过程都是一样的,只是终止状态不同。
于是这继续启发去抛弃二分,而是考虑一个增量的形式。
具体来说,考虑维护当前的堆与 \(\max f_u, s'\)。
初始 \(f_u = 0, s' = a_u\),代表走到了 \(u\) 这个点。
如果某个时刻 \(f_u + s' \ge 0\),那么就说明以 \(s\) 进入,最后得到的是 \(s + f_u + s'\ge s\) 一定不亏 ,那么此时就已经求解出了这个 \(\max f_u\) 了。
否则说,只能继续扩展,取出堆中最大的 \(f_v\) 并向 \(v\) 扩展:
- 如果当前的 \(s' + f_v\ge 0\),那么就可以直接走并更新 \(s'\) 的值。
- 如果说 \(s' + f_v < 0\),但是因为选 \(v\) 已经是最优决策,想要不亏只能走 \(v\),那么只能选择提高 \(s'\) 的值使得 \(s' + f_v\ge 0\)。
假设最后提升到了 \(s''\),那么对应的,\(f_u\) 即需要的钱数也需要增加,才能保证这个过程非负,所以 \(f_u\leftarrow f_u - (s'' - s')\)。
那么因为贪心的想得到 \(\max f_u\),所以肯定会让 \(s'' - s'\) 的值尽量小,所以只会让 \(s'' = - f_v\)。
于是此时会更新 \(f_u\leftarrow f_u - (-f_v - s'), s' \leftarrow f_v\),那么此时就满足 \(s' + f_v\ge 0\) 了,像上面一样然后再扩展到 \(v\)。
于是抛弃二分改为增量,就得到了 \(\mathcal{O}(n^2\log n)\) 的做法。
接下来考虑优化这个过程。
考虑到这个求解的过程中有很多看着很无用的步骤,或者说是,处理了很多无用的信息:
如果已经满足了 \(s' + f_v\ge 0\),那么走进 \(v\) 就一定不会亏,那么为什么还要一步一步走而不是一次性走完不亏的这一部分呢?
于是考虑直接维护对于 \(f_u\) 不亏的这一部分的信息。
考虑关心这一部分的什么东西:
- \(g_u = s + f_u\)。因为不能一个一个走了,所以就要提前知道走完这一部分能赚多少。
- \(q_u\)。相同的,因为不能一个一个扩展维护这个堆,所以要预存下还能扩展且没扩展的点的堆。
那么在 \(s' + f_v\ge 0\) 后,就可以直接加上 \(v\) 这一部分赚的钱:\(s'\leftarrow s' + g_v\);并且直接一次性扩展完 \(v\) 这个部分的点,更新能够扩展的点:\(q_u\leftarrow \operatorname{merge}(q_u, q_v)\)。
使用可并堆实现,可以做到 \(\mathcal{O}(n\log n)\)。
#include<bits/extc++.h>
using ll = long long;
constexpr ll inf = 2e18;
constexpr int maxn = 3e5 + 10;
int n; ll s;
ll a[maxn], f[maxn], g[maxn];
struct cmp {
bool operator () (const int &x, const int &y) {
return f[x] < f[y];
}
};
__gnu_pbds::priority_queue<int, cmp> Q[maxn], q;
std::vector<int> son[maxn];
void dfs(int u) {
for (int v : son[u]) {
dfs(v);
}
f[u] = 0ll;
ll res = a[u];
for (int v : son[u]) Q[u].push(v);
if (res + f[u] >= 0) {
g[u] = res + f[u];
return ;
}
while (! Q[u].empty()) {
int v = Q[u].top(); Q[u].pop();
if (f[v] == -inf) break;
if (res + f[v] < 0) {
f[u] -= -f[v] - res, res = -f[v];
}
res += g[v], Q[u].join(Q[v]);
if (res + f[u] >= 0) {
g[u] = res + f[u];
return ;
}
}
f[u] = -inf;
}
int main() {
scanf("%d%lld", &n, &s);
for (int i = 1, fa; i <= n; i++) {
scanf("%lld%d", &a[i], &fa);
son[fa].push_back(i);
}
dfs(0);
ll res = s;
q.push(0);
while (! q.empty()) {
int u = q.top(); q.pop();
if (res + f[u] >= 0) res += a[u];
else break;
for (int v : son[u]) q.push(v);
}
printf("%lld\n", res - s);
return 0;
}
浙公网安备 33010602011771号