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;
}
posted @ 2025-02-20 17:08  rizynvu  阅读(32)  评论(0)    收藏  举报