做题记录 #6

NOIP Day8A. 探测 (3)

2025.11.20

很有趣的题。我在考场上发现,因为为了满足全部限制条件,类似于这些限制条件的点一起走,汇聚到同一点,降低到同一个距离。由于保证答案存在,因此直接两两找汇聚点也是可以的,最后相当于找到一个 \(x\),求距离它为 \(d\) 的点集。可以从 \(x\) 开始 DFS,到对应深度就加入。但是不能进入存在限制点的子树,因为这会使得限制对应距离缩短,使条件不满足。找汇聚点这个事情,以前模拟赛也做过,可以倍增带 log 做。最后的复杂度也是 \(\mathcal{O}(n\log n)\) 的。

不过有着更加平凡的做法。比如考虑维护到每个限制点的距离,可以按 dfn 拍成序列,现在走边,例如从儿子到父亲,边权为 \(w\)。此时只需要维护子树加 \(w\),子树外减 \(w\)。这个事情可以线段树维护确切值,然后改限制点 \(i\) 的权值为 \(dis_i-y_i\),此时相当于问是不是线段树变成全 \(0\) 了。这个事情可以维护 min/max 之类的,反正随便做。复杂度也是 \(\mathcal{O}(n\log n)\)。常数应该比我的做法小一点。

不过复杂度可以更优。如果想到了上面的线段树做法,其实还可以直接维护距离的 哈希值!比如给每个限制点随机赋权,维护 \(\sum w_idis_i\),这个事情提前计算一下子树内 \(\sum w\) 即可,然后一样换根即可。复杂度是 \(\mathcal{O}(n)\) 的,但是牺牲了一点确定性。

我的做法也有着优化前途。如果不去做两两合并,其实还可以考虑当前点是否和子树内所有限制点距离都相等。然后内外其实都可以来一遍,距离相等就找到了这个最后的 \(x\)。换根不难做到 \(\mathcal{O}(n)\)

做法很多,但是并没有补其他的 /hsh

Code
#include <algorithm>
#include <cassert>
#include <iostream>
#include <numeric>
#include <vector>

using namespace std;
using LL = long long;
using PII = pair<int, int>;
constexpr int kN = 5e5 + 1;

int T, n, k;
int px[kN], py[kN];
vector<PII> e[kN];
int dis[kN], dep[kN], fa[kN][20];
void Init(int x, int f) {
  fa[x][0] = f;
  for (int d = 1; d < 20; d++)
    fa[x][d] = fa[fa[x][d - 1]][d - 1];
  for (auto P : e[x]) {
    int v = P.first, w = P.second;
    if (v != f) {
      dis[v] = dis[x] + w;
      dep[v] = dep[x] + 1, Init(v, x);
    }
  }
}
int LCA(int x, int y) {
  int ret = 0;
  dep[x] < dep[y] && (swap(x, y), 0);
  for (int d = 19; d >= 0; d--) {
    if (dep[fa[x][d]] >= dep[y])
      x = fa[x][d];
  }
  if (x == y)
    return x;
  for (int d = 19; d >= 0; d--) {
    if (fa[x][d] != fa[y][d])
      x = fa[x][d], y = fa[y][d];
  }
  return fa[x][0];
}
PII Solve(int ax, int ay, int bx, int by) {
  int lca = LCA(ax, bx);
  if (ay - dis[ax] < by - dis[bx])
    swap(ax, bx), swap(ay, by);
  // cerr << ax << ' ' << ay << ' ' << bx << ' ' << by << ' ' << lca << '\n';
  ay -= dis[ax] - dis[lca], ax = lca;
  int len = dis[bx] - dis[lca];
  // ay - (len - y) == by - y, 2y = by + len - ay
  assert((by + len - ay) % 2 == 0);
  int y = (by + len - ay) / 2, x = bx;
  // cerr << x << ' ' << y << '\n';
  for (int d = 19; d >= 0; d--) {
    if (int f = fa[x][d]; f != 0 && dis[bx] - dis[f] <= y)
      x = f;
  }
  assert(dis[bx] - dis[x] == y);
  assert(by - y >= 0);
  return {x, by - y};
}

vector<int> ans;
bool tag[kN], tx[kN];
bool Dfs(int x, int fa, int w) {
  bool ret = tx[x];
  dis[x] = dis[fa] + w;
  for (auto P : e[x]) {
    if (P.first != fa)
      ret |= Dfs(P.first, x, P.second);
  }
  return ret;
}
void Get(int x, int fa, int tgt) {
  if (dis[x] == tgt)
    ans.push_back(x);
  for (auto P : e[x]) {
    if (int v = P.first; v != fa && !tag[v])
      Get(v, x, tgt);
  }
}

int main() {
#ifndef ONLINE_JUDGE
  freopen("input.in", "r", stdin);
  freopen("output.out", "w", stdout);
#endif
  cin.tie(0)->sync_with_stdio(0);
  for (cin >> T; T--;) {
    cin >> n >> k;
    for (int i = 1, u, v, w; i < n; i++) {
      cin >> u >> v >> w;
      e[u].emplace_back(v, w), e[v].emplace_back(u, w);
    }
    for (int i = 1; i <= k; i++)
      cin >> px[i] >> py[i], tx[px[i]] = 1;
    if (k == 0) {
      cout << n << '\n';
      for (int i = 1; i <= n; i++)
        cout << i << ' ';
      cout << '\n';
      continue;
    }
    
    dis[1] = 0, Init(1, 0);
    int x = px[1], y = py[1];
    for (int i = 2; i <= k; i++) {
      auto P = Solve(x, y, px[i], py[i]);
      x = P.first, y = P.second;
    }
    // cerr << x << ' ' << y << '\n';
    dis[x] = 0;
    for (auto P : e[x]) {
      if (Dfs(P.first, x, P.second))
        tag[P.first] = 1;
    }
    Get(x, 0, y);
    // if (ans.empty())
    //   cerr << T << '\n';
    assert(!ans.empty());
    sort(ans.begin(), ans.end());
    cout << ans.size() << '\n';
    for (auto i : ans)
      cout << i << ' ';
    cout << '\n';

    ans.clear();
    for (int i = 1; i <= n; i++)
      e[i].clear(), tag[i] = tx[i] = 0;
  }
  return 0;
}

NOIP Day8B. 异或 (6.5)

2025.11.20

很难的数据结构题。看到这个题,发现可以用线段树,或者更像是 01-Trie 去维护。区间下标异或操作,其实如果询问的是一个整线段树节点对应区间,那么异或之后的集合还会是这个区间,但是更高位的更改下标会类似于一个节点指向了同层的另一个节点。很难不让人想到类似可持久化的实现方式。由于是可持久化区间修改,以及这个修改比较阴间,不难发现标记永久化是必需的。

那么具体怎么实现呢??考虑到标记永久化,查询的时候需要多传一个参数,即当前到根路径上的标记异或和。想清楚这个异或操作改变的只是下标,所以这个标记只影响线段树向下走的时候,选左还是选右。整区间仍然可以用节点的 sum。对于修改,会发现我还需要知道我要指向哪个区间。这个事情可以传两个参,一个是实际修改区间节点到根的标记异或和 \(p\)(初始 \(0\)),一个是目标区间节点到根的标记异或和 \(q\)(初始 \(k\)),两个一起向下跳。遇到整区间的时候,需要复制一个目标区间过来,而这个目标区间内部是还要按 XOR \(q\) 跳的,当前区间却是按 XOR \(p\),因此复制过来还要把标记 XOR \(p\oplus q\)。比较需要清醒的脑子才能想明白。考场上口胡了但是没有动手写,可能真去写还有不少细节会遇到困难。

此时你写完了,发现直接过了。抬头一看,空间限制 128MB,看上去像卡空间来着。但是数据造水了,卡了个寂寞。那么主席树其实是可以做到空间 \(\mathcal{O}(n)\) 的!不难发现每做一次操作,要同时新增 \(\log n\) 的空间和时间需求,而我只在乎最后形成的那棵树,而枚举这棵树的复杂度只是 \(\mathcal{O}(n)\) 的。因此,如果我每过 \(n/\log n\) 次操作就枚举整棵树重构,重新建一棵线段树,把之前的东西全删掉。这样只需要 \(n\log n\) 的时间重构,没有影响上界,而空间变成了大常数 \(\mathcal{O}(n)\)。非常有趣。

Code
#include <algorithm>
#include <cassert>
#include <iostream>
#include <numeric>
#include <vector>

using namespace std;
using LL = long long;
using PII = pair<int, int>;
constexpr int kN = 1 << 19;

int n, q, N;
struct Node {
  int ls, rs, tag;
  LL sum;
} t[10 * kN];
int a[kN], tot, rt;
void PushUp(int x) { t[x].sum = t[t[x].ls].sum + t[t[x].rs].sum; }
int Build(int l, int r) {
  int x = ++tot;
  t[x] = {0, 0, 0};
  if (l == r)
    return t[x].sum = a[l], x;
  int mid = (l + r) / 2;
  t[x].ls = Build(l, mid);
  t[x].rs = Build(mid + 1, r);
  return PushUp(x), x;
}
void Recover(int x, int l, int r, int k, int dep = N) {
  k ^= t[x].tag;
  if (l == r)
    return a[l] = t[x].sum, void();
  int mid = (l + r) / 2;
  bool o = k >> dep & 1;
  Recover(!o ? t[x].ls : t[x].rs, l, mid, k, dep - 1);
  Recover(!o ? t[x].rs : t[x].ls, mid + 1, r, k, dep - 1);
}

int Update(int x, int tgt, int L, int R, int cur, int k, int l, int r, int dep = N) {
  if (R < l || r < L)
    return x;
  int y = ++tot;
  t[y] = t[x];
  // cerr << x << ' ' << tgt << ' ' << L << ' ' << R << ' ' << cur << ' ' << k << ' ' << l << ' ' << r << ' ' << dep << '\n';
  if (L <= l && r <= R) {
    t[y] = t[tgt], t[y].tag ^= k ^ cur;
    return y;
  }
  int mid = (l + r) / 2;
  cur ^= t[x].tag, k ^= t[tgt].tag;
  bool p = (cur >> dep & 1), q = (k >> dep & 1);
  t[y].ls = Update(!p ? t[x].ls : t[x].rs, !q ? t[tgt].ls : t[tgt].rs, L, R, cur, k, l, mid, dep - 1);
  t[y].rs = Update(!p ? t[x].rs : t[x].ls, !q ? t[tgt].rs : t[tgt].ls, L, R, cur, k, mid + 1, r, dep - 1);
  p && (swap(t[y].ls, t[y].rs), 0);
  return PushUp(y), y;
}
void Update(int l, int r, int k) {
  // cerr << l << ' ' << r << ' ' << k << '\n';
  rt = Update(rt, rt, l, r, 0, k, 0, n - 1);
  if (tot >= 8 * n) {
    Recover(rt, 0, n - 1, 0);
    tot = 0, rt = Build(0, n - 1);
  }
}
LL Sum(int x, int L, int R, int k = 0, int l = 0, int r = n - 1, int dep = N) {
  if (R < l || r < L)
    return 0;
  else if (L <= l && r <= R)
    return t[x].sum;
  int mid = (l + r) / 2;
  k ^= t[x].tag;
  bool o = (k >> dep & 1);
  return Sum(!o ? t[x].ls : t[x].rs, L, R, k, l, mid, dep - 1)
       + Sum(!o ? t[x].rs : t[x].ls, L, R, k, mid + 1, r, dep - 1);
}

int main() {
#ifndef ONLINE_JUDGE
  freopen("input.in", "r", stdin);
  freopen("output.out", "w", stdout);
#endif
  cin.tie(0)->sync_with_stdio(0);
  cin >> n >> q;
  N = __lg(n) - 1;
  for (int i = 0; i < n; i++)
    cin >> a[i];
  rt = Build(0, n - 1);
  for (int o, l, r, k; q--;) {
    cin >> o >> l >> r;
    if (o == 1)
      cin >> k, Update(l, r - 1, k);
    else
      cout << Sum(rt, l, r - 1) << '\n';
    // if (o == 1) {
    //   Recover(rt, 0, n - 1, 0);
    //   for (int i = 0; i < n; i++)
    //     cout << a[i] << ' ';
    //   cout << '\n';
    // }
  }
  return 0;
}

NOIP Day8C. 抓捕 (5.5)

2025.11.20

考场上没想明白任何东西,全是无效思考,完全磨时间而已。很明显遇到一个问题不知道怎么解决,应该想明白问题然后猜性质,而不是发呆。

首先,这个移动建出图来可以发现是基环树森林。那么此时修改边有几种可能,首先是单基环树内部修改,还可能是把一个基环树 拆环 / 断树 接在另一个的 环 / 树 上。可能性很多,此时需要一些选择的方法,需要一些性质。首先是这个单基环树内部修改,可以断树接到 另一个树 / 环 上,很复杂;但是更可以断掉环,造成一个自环,这样整棵树的人都将被抓捕。因此,如果要做此类操作,一定不如直接造自环优。考场上是连这个都没发现,不知道拿什么 NOIP。

此时情况顿时明朗了。由于自环的性质,一定可以花 \(k\) 次操作抓到前 \(k\) 大基环树上的所有人。如果不造自环,那么每一步都必然连接两棵基环树,那么如果确定下来最后的图,肯定是连通了的所有人全部走上环里,此时人与人的区别类似“深度”\(\mod p\) 分类。那么连基环树的时候,断树下来是很亏的,因为直接断环接可以包含接树的贡献,只需要一点偏移。因为可以任意偏移,所以可以把\(\mod p\) 最大的一类拿出来拼一起,这样答案更优。

此时考虑暴力,可以枚举目标的基环树,了解目标环长 \(p\)。此时对于每棵树,一定是找到最优的断环方式,使得最大的\(\mod p\) 类人数最大。这个事情暴力做是平方的,但是可以考虑变化量。也就是按顺序枚举断环的哪条边,一开始是一遍 DFS 求所有点深度。此时每次可以把根塞到环中深度最大的点下面。相对来看,就是把这个点以及相应子树的深度加上了环长。至于维护,直接记一些 \(cnt\),每次枚举这样的一个子树,总的复杂度是 \(\mathcal{O}(sz)\) 的。因为对于每个基环树每次都要做一遍,所以总复杂度是平方的。

怎么优化呢???发现对每棵树,都对很多相同的环长重复算了很多次!仔细分析一下,发现不同的环长只有 \(\mathcal{O}(\sqrt{n})\) 种,枚举这个环长,复杂度变成 \(\mathcal{O}(n\sqrt{n})\),可以通过此题。

用的全是经典思路,已严肃反思。

Code
#include <algorithm>
#include <cassert>
#include <iostream>
#include <numeric>
#include <vector>

using namespace std;
using LL = long long;
using PII = pair<int, int>;
constexpr int kN = 1e5 + 1;

int T, n, p[kN];
bool vis[kN], onc[kN];
vector<int> e[kN], tr[kN];
vector<vector<int>> vcr;
vector<int> vec;

void Init(int x, int fa, int bel) {
  vis[x] = 1;
  tr[bel].push_back(x);
  for (auto v : e[x]) {
    if (v != fa && !onc[v])
      Init(v, x, bel);
  }
}

int ans[kN], buc[kN];
void Initialize() {
  vector<int> sz;
  for (int i = 1; i <= n; i++) {
    if (vis[i])
      continue;
    int x = i;
    vector<int> cir;
    for (; !vis[x]; vis[x] = 1, x = p[x]);
    for (; !onc[x]; onc[x] = 1, x = p[x])
      cir.push_back(x);
    reverse(cir.begin(), cir.end());
    vec.push_back(cir.size());
    vcr.push_back(cir);
    int siz = 0;
    for (auto p : cir)
      Init(p, 0, p), siz += tr[p].size();
    sz.push_back(siz);
  }
  sort(vec.begin(), vec.end());
  vec.erase(unique(vec.begin(), vec.end()), vec.end());
  sort(sz.begin(), sz.end(), greater<int>());
  for (int i = 0; i < sz.size(); i++)
    ans[i + 1] = ans[i] + sz[i];
}

int dep[kN], cnt[kN], mx, tot[kN];
int Dfs(int x, int fa, int V) { 
  vis[x] = 1;
  dep[x] = dep[fa] + 1;
  int w = ++cnt[dep[x] % V];
  tot[w]++, mx = max(mx, w);
  int siz = 1;
  for (auto v : e[x]) {
    if (v != fa && !vis[v])
      siz += Dfs(v, x, V);
  }
  return siz;
}
int Solve(vector<int> &cir, int V) {
  dep[cir[0]] = 0;
  int siz = Dfs(cir[0], 0, V) + cir.size();
  // for (auto i : cir)
  //   cout << i << ' ';
  // cout << '\n';
  int ret = mx, sz = cir.size();
  for (int i = 0; i < sz - 1; i++) {
    // cerr << cir[i] << ' ';
    for (auto x : tr[cir[i]]) {
      int &lst = cnt[dep[x] % V];
      tot[lst--]--, tot[lst]++;
      for (; mx > 0 && tot[mx] == 0; mx--);
      int &w = cnt[(dep[x] + sz) % V];
      tot[w++]--, tot[w]++;
      mx = max(mx, w);
    }
    // cerr << cir[i] << ' ' << mx << '\n';
    ret = max(ret, mx);
  }
  fill_n(tot, mx + 1, 0), mx = 0;
  for (int i = 0; i <= min(V, siz); i++)
    cnt[i] = 0;
  return ret;
}

int main() {
#ifndef ONLINE_JUDGE
  freopen("input.in", "r", stdin);
  freopen("output.out", "w", stdout);
#endif
  cin.tie(0)->sync_with_stdio(0);
  for (cin >> T; T--;) {
    cin >> n;
    for (int i = 1; i <= n; i++)
      cin >> p[i], e[p[i]].push_back(i);
    Initialize();
    // fill_n(vis + 1, n, 0);
    // cout << Solve(vcr[1], 2) << '\n';
    for (auto sz : vec) {
      // cerr << sz << ' ';
      fill_n(vis + 1, n, 0);
      int tgt = 0;
      for (auto &v : vcr) {
      // cerr << T << '\n';
        int w = Solve(v, sz);
        // cerr << w << ' ';
        buc[w]++;
        if (v.size() == sz)
          tgt = max(tgt, w);
      }
      // cerr << '\n';
      buc[tgt]--;
      ans[0] = max(ans[0], tgt);
      int j = 1, cur = tgt;
      for (int i = n; i >= 0; i--) {
        for (; buc[i] > 0; buc[i]--, j++)
          cur += i, ans[j] = max(ans[j], cur);
      }
    }
    for (int i = 0; i <= n; i++) {
      if (i > 0)
        ans[i] = max(ans[i], ans[i - 1]);
      cout << ans[i] << ' ';
    }
    cout << '\n';

    vec.clear(), vcr.clear();
    for (int i = 0; i <= n; i++) {
      ans[i] = vis[i] = onc[i] = 0;
      e[i].clear(), tr[i].clear();
    }
  }
  return 0;
}

NOIP Day8D. 商店 (7)

2025.11.21

赛时没开,赛后自己想了会,想到了挺多,然后没忍住看了题解 /hsh

最低档暴力,不难设计状态 \(f_{i,j}\) 表示考虑了 \([i,n]\) 这个后缀,买到 \(j\) 个物品的最小最初金币数,感觉最好能做到三方,不具有任何优化前途。但是发现这个 DP 是凸的,可以维护差分然后归并合并。但是贡献很奇怪,而且感觉同样没有优化前途。不知道线段树咋做,我连平方都不会。

那么观察性质,发现对于一个购买方案,一定可以对它进行排序。而且购买物品越平均越省钱。考虑维护这个最佳购买方案,此时加入点 \(i\),在此点能买则买,发现如果买的比后面的点还要多,那么可以把多出来的摊到后面去 买更便宜的。暴力维护看上去可以平方。接下来应该可以维护连续段,但是我没太想清楚。

事实上是类似的。考虑维护连续段,此时当前点能买的物品比后面的连续段多,那么让后面买一定更便宜且不劣。于是均摊到连续段的每一个点上,多出来一些能买就填后缀。此时又可能比后面大了,可能还要合并两个连通块,与合点是类似的。发现维护真的连续段其实很麻烦,因为做完之后还要把能再买一个的后缀去裂开,还有更多合并的小事情。但是这个其实没有意义,发现并不需要维护真的连续段,只需要表示“这一段的金币是可以均摊的”即可,即允许后缀购买物品数与前面不同。此时合并是非常简单的。而每次合并连续段就一定是一段前缀,用栈维护一下即可。

但是发现我们忽略了连续块之间的贡献。后面可能有地方就差一点点钱能再买一个物品,前者剩了钱可以给后面的用。对此,可以发现后面人的钱反正也不能给前面的用,于是维护一下后缀“再买一个”所需最少金币数,衔接处判一下即可。

\(c\) 排序以及 计算连续段均摊可购买物品数量要二分,复杂度 \(\mathcal{O}(n\log n)\)。正解对购买机制的利用非常极致,非常需要性质的挖掘以及细节的考虑。如果我继续想下去,我猜我会忘记这个连续块之间的贡献,然后发现之后认为做不了/hsh 的确非常考察选手的大脑活跃度,还得练啊。

Code
#include <algorithm>
#include <cassert>
#include <iostream>
#include <numeric>
#include <vector>

using namespace std;
using LL = long long;
using PII = pair<int, int>;
constexpr int kN = 1.2e6 + 1;

int n, m;
LL a[kN], c[kN], pre[kN];
struct Node {
  int cnt, k;
  LL sum, ans, con;
};
vector<Node> s;
void Init() {
  cin >> n >> m;
  for (int i = 1; i <= n; i++)
    cin >> a[i];
  for (int i = 1; i <= m; i++)
    cin >> c[i];
  sort(c + 1, c + m + 1);
  for (int i = 1; i <= m; i++)
    pre[i] = pre[i - 1] + c[i];
}
int Get(LL w) { return upper_bound(pre + 1, pre + m + 2, w) - pre - 1; }
int main() {
#ifndef ONLINE_JUDGE
  freopen("input.in", "r", stdin);
  freopen("output.out", "w", stdout);
#endif
  cin.tie(0)->sync_with_stdio(0);
  Init();
  pre[m + 1] = c[m + 1] = 8e18;
  LL now = 0, Ans = 0;
  for (int i = n; i >= 1; i--) {
    int cnt = 1, k = Get(a[i]);
    LL sum = a[i];
    for (; !s.empty() && s.back().k <= k; s.pop_back()) {
      cnt += s.back().cnt, sum += s.back().sum;
      k = Get(sum / cnt), now -= s.back().ans;
    }
    LL low = 1ll * k * cnt, hi = (sum - cnt * pre[k]) / c[k + 1];
    LL lst = (sum - cnt * pre[k]) % c[k + 1];
    LL ans = low + hi, con = c[k + 1];
    if (!s.empty() && lst >= s.back().con)
      ans++, con += s.back().con;
    else if (!s.empty())
      con = min(con, s.back().con);
    s.push_back({cnt, k, sum, ans, con - lst});
    now += ans;
    // cerr << now << ' ';
    Ans += now ^ i;
  }
  cout << Ans << '\n';
  return 0;
}
posted @ 2025-11-22 01:17  Lightwhite  阅读(25)  评论(0)    收藏  举报