做题记录 #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;
}

浙公网安备 33010602011771号