LOJ #3166. 「CEOI2019」魔法树

Posted cutx64

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了LOJ #3166. 「CEOI2019」魔法树相关的知识,希望对你有一定的参考价值。

LOJ #3166. 「CEOI2019」魔法树

首先可以列出一个 \\(\\text{dp}\\) 状态: 设 \\(f_{u,t}\\) 表示恰好在 \\(t\\) 时刻剪断节点 \\(u\\) 与其父亲的边可获得的最大收益.

这显然是一个树上背包. 先不考虑节点 \\(u\\) 自身的贡献, 在合并两个儿子 \\(u,v\\) 时的状态转移方程应为:

\\[f\'_{u,t}=\\max\\{f_{u,t}+\\max_{1\\le i\\le t}{f_{v,i}},f_{v,t}+\\max_{1\\le i\\le t}{f_{u,i}}\\} \\]

然后再考虑节点 \\(u\\) 自身的贡献, \\(f\'_{u,d_u}=\\max\\limits_{1\\le i\\le d_u}\\{f_{u,i}\\}+w_u\\).

但这样的 \\(\\text{dp}\\) 的时间空间复杂度均为 \\(\\mathrm O(n^2)\\). 考虑优化.

  1. 线段树合并

发现 \\(\\text{dp}\\) 背包部分的转移是一个前缀 \\(\\max\\), 于是可以使用线段树合并进行优化.

  • \\(\\textbf{trick}\\) 线段树合并优化 \\(\\text{dp}\\) 前/后缀转移.

朴素的线段树合并是相同的位置进行合并, 在这个过程中是从左到右进行合并的. 于是在合并的同时, 可以记录两颗线段树目前合并位置的前缀值, 这样就完成了前缀转移. 同理, 线段树合并也可以优化后缀转移.

时间复杂度: \\(\\mathrm O(n\\log n)\\), 空间复杂度: \\(\\mathrm {O(n\\log n)}\\).

参考代码
#include "bits/stdc++.h"
using namespace std;
static constexpr int Maxn = 1e5 + 5;
int n, m, k;
vector<int> g[Maxn];
int d[Maxn];
int64_t w[Maxn];
struct treedot {
  int ls, rs;
  int64_t val;
  int64_t lz;
} tr[Maxn << 5];
int tot, root[Maxn];
void pushup(int p, int l, int r) {
  tr[p].val = max(tr[tr[p].ls].val, tr[tr[p].rs].val);
} // pushup
void apply(int p, int l, int r, int64_t v) {
  if (!p) return ;
  tr[p].val += v, tr[p].lz += v;
} // apply
void pushdown(int p, int l, int r) {
  if (!p) return ;
  int mid = (l + r) >> 1;
  if (tr[p].lz != 0) {
    apply(tr[p].ls, l, mid, tr[p].lz);
    apply(tr[p].rs, mid + 1, r, tr[p].lz);
    tr[p].lz = 0;
  }
} // pushdown
void modify(int &p, int l, int r, int x, int64_t v) {
  if (!p) p = ++tot;
  if (l == r) {
    tr[p].val = max(tr[p].val, v);
  } else {
    int mid = (l + r) >> 1;
    pushdown(p, l, r);
    if (x <= mid) modify(tr[p].ls, l, mid, x, v);
    else modify(tr[p].rs, mid + 1, r, x, v);
    pushup(p, l, r);
  }
} // modify
int64_t query(int p, int l, int r, int L, int R) {
  if (!p || L > r || l > R) return 0;
  if (L <= l && r <= R) return tr[p].val;
  int mid = (l + r) >> 1;
  pushdown(p, l, r);
  return max(query(tr[p].ls, l, mid, L, R), query(tr[p].rs, mid + 1, r, L, R));
} // query
int join(int u, int v, int l, int r, int64_t pu, int64_t pv) {
  if (!u && !v) return 0;
  if (!u) { apply(v, l, r, pv); return v; }
  if (!v) { apply(u, l, r, pu); return u; }
  if (l == r) {
    pu = max(pu, tr[v].val);
    pv = max(pv, tr[u].val);
    tr[u].val = max(tr[u].val + pu, tr[v].val + pv);
    return u;
  }
  int mid = (l + r) >> 1;
  pushdown(u, l, r); pushdown(v, l, r);
  int64_t lu_val = tr[tr[u].ls].val, lv_val = tr[tr[v].ls].val;
  tr[u].ls = join(tr[u].ls, tr[v].ls, l, mid, pu, pv);
  tr[u].rs = join(tr[u].rs, tr[v].rs, mid + 1, r, max(pu, lv_val), max(pv, lu_val));
  pushup(u, l, r);
  return u;
} // join
void dfs(int u, int fa) {
  for (const int &v: g[u]) if (v != fa) {
    dfs(v, u);
    root[u] = join(root[u], root[v], 1, k, 0LL, 0LL);
  }
  if (d[u] != 0) {
    int64_t W = query(root[u], 1, k, 1, d[u]);
    modify(root[u], 1, k, d[u], W + w[u]);
  }
} // dfs
int main(void) {
  scanf("%d%d%d", &n, &m, &k);
  for (int i = 2, pi; i <= n; ++i) {
    scanf("%d", &pi);
    g[pi].push_back(i);
    g[i].push_back(pi);
  }
  for (int i = 1, v; i <= m; ++i) {
    scanf("%d", &v);
    scanf("%d%lld", &d[v], &w[v]);
  }
  dfs(1, 0);
  printf("%lld\\n", tr[root[1]].val);
  exit(EXIT_SUCCESS);
} // main
  1. 树上启发式合并

容易发现, 节点 \\(u\\) 有贡献\\(\\text{dp}\\) 值最多就 \\(\\text{sz}_u\\) 个, 其中 \\(\\text{sz}_u\\) 表示子树 \\(u\\) 的大小. 于是可以想到树上启发式合并.

注意到直接合并 \\(f\\) 的值复杂度会爆炸. 考虑合并 \\(f\\) 的前缀值.

\\(g_{u,t}=\\max\\limits_{1\\le i\\le t}f_{u,i}\\) 易知 \\(g_u\\) 是单调不降的. 将上述的转移方程改写, 有

\\[g_{u,t}=\\max\\left\\{[d_u\\le t](g_{u,d_u}+w_u),\\,\\sum\\limits_{v\\in \\text{son}_u}g_{v,t}\\right\\} \\]

发现 \\(g_{u}\\) 中不同的值最多有 \\(\\text{sz}_u\\) 个, 于是可以用 map 维护 \\(g_u\\) 不同值域的最小下标和值.

前面那一部分可以直接暴力更新, 但后面那一部分不太好直接启发式合并, 于是改用 map 维护 \\(g_u\\) 差分数组. 这样就可以直接启发式合并了.

时间复杂度: \\(\\mathrm O(n\\log^2n)\\), 空间复杂度: \\(O(n)\\).

参考代码
#include "bits/stdc++.h"
using namespace std;
static constexpr int Maxn = 1e5 + 5;
int n, m, k;
vector<int> g[Maxn];
int d[Maxn];
int64_t w[Maxn];
int sz[Maxn], son[Maxn];
map<int, int64_t> s[Maxn];
void join(map<int, int64_t> &x, map<int, int64_t> &y) {
  for (const auto &[t, v]: y) x[t] += v;
  y.clear();
} // join
void dfs(int u, int fa) {
  sz[u] = 1; son[u] = 0;
  for (const int &v: g[u]) if (v != fa) {
    dfs(v, u), sz[u] += sz[v];
    if (!son[u] || sz[son[u]] < sz[v]) son[u] = v;
  }
  if (son[u]) join(s[son[u]], s[u]), s[u].swap(s[son[u]]);
  for (const int &v: g[u]) if (v != fa) {
    if (v != son[u]) {
      join(s[u], s[v]);
    }
  }
  if (d[u] != 0) {
    s[u][d[u]] += w[u];
    int W = w[u];
    for (auto it = next(s[u].find(d[u])); it != s[u].end(); ) {
      if (it->second > W) {
        it->second -= W;
        break;
      }
      W -= it->second;
      s[u].erase(it++);
    }
  }
} // dfs
int main(void) {
  scanf("%d%d%d", &n, &m, &k);
  for (int i = 2, pi; i <= n; ++i) {
    scanf("%d", &pi);
    g[pi].push_back(i);
    g[i].push_back(pi);
  }
  for (int i = 1, v; i <= m; ++i) {
    scanf("%d", &v);
    scanf("%d%lld", &d[v], &w[v]);
  }
  dfs(1, 0);
  int64_t ans = 0;
  for (const auto &[t, v]: s[1]) ans += v;
  printf("%lld\\n", ans);
  exit(EXIT_SUCCESS);
} // main

@loj - 2483@「CEOI2017」Building Bridges

目录


@[email protected]

有 n 根柱子依次排列,第 i 根柱子的高度为 hi 。现可以花费 (hi - hj)^2 的代价建桥架在第 i 根柱子和第 j 根柱子之间。
所有用不到的柱子都会被拆除,第 i 根柱子被拆除的代价为 wi 。
求用桥把第 1 根柱子和第 n 根柱子连接的最小代价。注意桥梁不能在端点以外的任何地方相交。

input
第一行一个正整数 n。 2 <= n <= 10^5。
第二行 n 个空格隔开的整数,依次表示 h1, h2, ..., hn。0 <= hi <= 10^6。
第三行 n 个空格隔开的整数,依次表示 w1, w2, ..., wn。0 <= |wi| <= 10^6。

output
输出一个整数表示最小代价,注意最小代价不一定是正数。

sample input
6
3 8 7 1 6 6
0 -1 9 1 2 0
sample output
17

@[email protected]

一个很 naive 的 dp:定义状态 (dp[i]) 表示将 1 与 i 连接起来的最小费用,并再定义一个前缀和 (s[i] = sum_{p=1}^{i}w[p]),则状态转移为:
[dp[i]=min{dp[j]+s[i]-s[j]+(h[i]-h[j])^2}]
满脸的斜率优化。

横坐标为 (x[j] = h[j]),纵坐标为(y[j] = dp[j] - s[j] + h[j]^2),斜率为 (k[i] = 2*h[i]),只和 i 有关的常数 (c[i] = s[i] + h[i]^2)
转移式变为:
[dp[i]=min{c[i]+y[j]-k[i]*x[j]}]

然而……斜率不单调就算了……TM 横坐标也不单调。
对于这种题,一是写平衡树,一是用 cdq 分治。
因为我这辈子都不会去写平衡树维护斜率的 cdq 分治非常的优秀,所以我就在这里讲一下 cdq。

感性描述一下我们的思想:我们把区间分为两部分,左半部分依照横坐标排序,右半部分依照斜率排序,同时保证左半部分所有的编号小于右半部分所有的编号。
在这个前提下,用左边去更新右边,就是一个简单的单调栈问题了。

我们当然不可能在每一层都去排一下序什么的,这样时间复杂度就退化成 O(nlog^2n) 的。
所以我们的解决方法是这样的:
首先我们把所有点按照斜率来排序,开始递归区间 [1, n]。
对于当前这一层 [l, r],将这些点按照编号与 mid 的关系,分成左右两部分,同时两部分内部都保持斜率单调的顺序。因为我们一开始递归的是 [1, n],按照上面这一套方法,递归 [l, r] 的时候这个区间内所有点的编号都在 [l, r] 范围内。
然后,先递归 [l, mid],求出这段区间的 dp 值,并在递归时以它们的横坐标为关键字进行排序(归并排序)。
再一套单调栈更新右半部分。递归 [mid, r] 求解。此时左右两部分都是以横坐标为关键字的有序状态。
在最后归并即可。

好像有些冗长……最好看一看代码确认一下细节。

@accepted [email protected]

#include<cstdio>
#include<algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 100000;
const ll INF = (1LL<<62);
struct node{
    ll w, h, c, k, x, y, dp;
    int pos;
}a[MAXN + 5], tmp[MAXN + 5], que[MAXN + 5];
bool cmp(node a, node b) {
    return a.k < b.k;
}
void cdq(int le, int ri) {
    if( le == ri ) {
        a[le].x = a[le].h;
        a[le].y = a[le].h*a[le].h + a[le].dp - a[le].w;
        return ;
    }
    int mid = (le + ri) >> 1, p = le, q = mid + 1, r = le;
    for(r = le;r <= ri;r++)
        if( a[r].pos <= mid ) tmp[p++] = a[r];
        else tmp[q++] = a[r];
    for(r = le;r <= ri;r++)
        a[r] = tmp[r];
    cdq(le, mid);
    int s = 1, t = 0;
    for(p = le;p <= mid;p++) {
        while( s < t && (que[t].y - que[t-1].y)*(a[p].x - que[t].x) >= (a[p].y - que[t].y)*(que[t].x - que[t-1].x) )
            t--;
        que[++t] = a[p];
    }
    for(q = mid + 1;q <= ri;q++) {
        while( s < t && a[q].k*(que[s+1].x - que[s].x) >= (que[s+1].y - que[s].y) )
            s++;
        a[q].dp = min(a[q].dp, a[q].c + que[s].y - que[s].x*a[q].k);
    }
    cdq(mid + 1, ri);
    p = le, q = mid + 1, r = le;
    while( p <= mid && q <= ri ) {
        if( a[p].x == a[q].x )
            tmp[r++] = (a[p].y < a[q].y) ? a[p++] : a[q++];
        else tmp[r++] = (a[p].x < a[q].x) ? a[p++] : a[q++];
    }
    while( p <= mid )
        tmp[r++] = a[p++];
    while( q <= ri )
        tmp[r++] = a[q++];
    for(r = le;r <= ri;r++)
        a[r] = tmp[r];
}
int main() {
    int n; scanf("%d", &n);
    for(int i=1;i<=n;i++)
        scanf("%lld", &a[i].h), a[i].pos = i;
    for(int i=1;i<=n;i++)
        scanf("%lld", &a[i].w), a[i].w += a[i-1].w;
    for(int i=1;i<=n;i++)
        a[i].k = 2*a[i].h, a[i].c = a[i].h*a[i].h + a[i-1].w, a[i].dp = INF;
    a[1].dp = 0;
    sort(a+1, a+n+1, cmp); cdq(1, n);
    for(int i=1;i<=n;i++)
        if( a[i].pos == n ) printf("%lld
", a[i].dp);
}

@[email protected]

cdq 分治真的太巧妙了。
我们需要维护三部分的有序性:斜率,横坐标,编号。
你看 cdq 分治,只需要一点点离线化,就可以顺利解决这三部分的矛盾。

巧妙,太巧妙了。



























以上是关于LOJ #3166. 「CEOI2019」魔法树的主要内容,如果未能解决你的问题,请参考以下文章

loj2480 [CEOI2017]One-Way Streets

LOJ#2369. 「BalticOI 2008」魔法石

[loj3301]魔法商店

loj 3301 「联合省选 2020 A」魔法商店 - 拟阵 - 保序回归

[CEOI2019]MAGIC TREE

#3164. 「CEOI2019」立方填词