CSP-S 2019 简要题解
现在补这套题原因大概有二:一是把其作为码力恢复练习(差点代码都打不来了),二是这场考试对我来说有着特殊意义。
两年前的考试游记见 https://www.cnblogs.com/ImagineC/p/11877762.html。
题目链接
https://loj.ac/p?keyword=CSP-S%202019
题解
A. 格雷码 / code
模拟。
#include<bits/stdc++.h>
using namespace std;
int n;
unsigned long long k;
int main() {
freopen("code.in", "r", stdin);
freopen("code.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
--n;
while (~n) {
cout << ((k >> n) & 1);
if (k >= 1ull << n) {
k = (1ull << n) - 1 + (1ull << n) - k;
}
--n;
}
cout << '\n';
return 0;
}
B. 括号树 / brackets
将左括号视为 \(+1\),右括号视为 \(-1\),记 \(s_x\) 表示从根到节点 \(x\) 的路径上所有节点的数字\((+1/-1)\)之和,那么对于节点 \(x\),我们实际上要统计它的祖先中有多少节点 \(y\) 满足 \(s_y = s_x\)。由于括号序列需要合法,我们还需要记录满足 \(s_k = s_x - 1\) 且深度最大的祖先节点 \(k\),并将其之前的贡献减去。
#include<bits/stdc++.h>
using namespace std;
const int N = 567890;
class my_array {
int a[N << 1];
public:
int& operator [] (int x) {
return a[N + x];
}
} pool, ban;
int n, a[N], father[N];
long long result[N];
vector<int> adj[N];
void dfs(int x) {
a[x] += a[father[x]];
int w = a[x], pre = ban[w];
ban[w] = pool[w + 1];
result[x] = pool[w] - ban[w - 1] + result[father[x]];
++pool[w];
for (auto y : adj[x]) {
dfs(y);
}
--pool[w];
ban[w] = pre;
}
int main() {
freopen("brackets.in", "r", stdin);
freopen("brackets.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
string brackets;
cin >> brackets;
for (int i = 0; i < n; ++i) {
a[i + 1] = brackets[i] == '(' ? 1 : -1;
}
for (int i = 2; i <= n; ++i) {
cin >> father[i];
adj[father[i]].push_back(i);
}
++pool[0];
dfs(1);
long long answer = 0;
for (int i = 1; i <= n; ++i) {
answer ^= i * result[i];
}
cout << answer << '\n';
return 0;
}
C. 树上的数 / tree
总体框架为贪心,即尽量把小数字放在编号小的节点。
假设节点 \(x\) 上的数字沿着路径 \(x \rightarrow p_1 \rightarrow p_2 \rightarrow \cdots \rightarrow p_k \rightarrow y\) 最终到达了节点 \(y\),那么有三个条件需要满足:
- \((x, p_1)\) 为所有与节点 \(x\) 相连的边中最早被删除的
- \((p_k, y)\) 为所有与节点 \(y\) 相连的边中最晚被删除的
- 对于上述路径上的任意中间节点 \(p_i\),路径中与该节点有关的两条边一定在所有与该节点相连的边中被连续删除
对于单个节点 \(x\),我们将所有与之相连的边视作特殊节点,那么上述三个条件分别对应特殊节点构成的图上的“标记起始点”、“标记终点”、“连有向边”三种操作。最终每个节点对应的特殊节点构成的图应满足:
- 由若干条链组成
- 无连入至起始点的边
- 无由终点连出的边
在每次 dfs 暴力寻找编号最小的可行节点时,特殊节点构成的图的合法性可以通过并查集和节点的出/入度来检验。
实现代码时需要注意的细节较多,可以通过为每一个节点额外建立一个虚特殊节点来减少使用并查集时特殊情况的判断。注意特判 \(n = 1\) 的情况,否则会无法通过 UOJ 的 Extra Test(尽管官方数据并没有这种情况)。
#include<bits/stdc++.h>
using namespace std;
const int N = 6789;
int n, p, id_cnt, a[N], id[N][N], father[N], size[N], degree[N][2]; // 0: in, 1: out
vector<int> adj[N], pool, path;
bool used[N];
int find(int x) {
return father[x] == x ? x : father[x] = find(father[x]);
}
void merge(int x, int y) { // x->y
++degree[x][1];
++degree[y][0];
x = find(x);
y = find(y);
father[x] = y;
size[y] += size[x];
}
void dfs(int x, int f) {
if (x != f && !used[x]) {
int y = id[x][f];
bool legal = true;
if (degree[x][0] || degree[y][1]) {
legal = false;
}
if (find(x) == find(y) && size[find(x)] <= adj[x].size()) {
legal = false;
}
if (legal) {
p = min(p, x);
}
}
for (auto y : adj[x]) {
if (y != f) {
int a = x == f ? x : id[x][f], b = id[x][y];
if (degree[b][0] || degree[a][1]) {
continue;
}
if (find(a) == find(b) && size[find(a)] <= adj[x].size()) {
continue;
}
dfs(y, x);
}
}
}
void dfs_path(int x, int goal, int f = 0) {
pool.push_back(x);
if (x == goal) {
path = pool;
return;
}
for (auto y : adj[x]) {
if (y != f) {
dfs_path(y, goal, x);
}
}
pool.pop_back();
}
int get(int x) {
p = n + 1;
dfs(x, x);
used[p] = true;
pool.clear();
dfs_path(x, p);
int m = path.size();
merge(path[0], id[path[0]][path[1]]);
for (int i = 0; i + 2 < m; ++i) {
int a = path[i], b = path[i + 1], c = path[i + 2];
merge(id[b][a], id[b][c]);
}
merge(id[path[m - 1]][path[m - 2]], path[m - 1]);
return p;
}
int main() {
freopen("tree.in", "r", stdin);
freopen("tree.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
int tt;
cin >> tt;
while (tt--) {
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
if (n == 1) {
cout << 1 << '\n';
continue;
}
for (int i = 1; i <= n; ++i) {
adj[i].clear();
}
for (int i = 1; i < n; ++i) {
int x, y;
cin >> x >> y;
adj[x].push_back(y);
adj[y].push_back(x);
}
id_cnt = n;
for (int i = 1; i <= n; ++i) {
for (auto j : adj[i]) {
id[i][j] = ++id_cnt;
}
}
for (int i = 1; i <= id_cnt; ++i) {
father[i] = i;
size[i] = 1;
}
memset(used, false, sizeof used);
memset(degree, 0, sizeof degree);
for (int i = 1; i <= n; ++i) {
cout << get(a[i]) << " \n"[i == n];
}
}
return 0;
}
D. Emiya 家今天的饭 / meal
答案为在不考虑食材使用限制的情况下的总方案数减去某种食材有超过一半的菜使用的方案数。
不考虑食材使用限制的情况下的总方案数为 \(\left(\prod_{i = 1}^n \left( 1 + \sum_{j = 1}^m a_{i, j}\right)\right) - 1\)。对于某种食材,有超过一半的菜使用它的方案数可以通过 dp 算得,可以设 \(f_{i, j, k}\) 表示在前 \(i\) 种烹饪方法对应的菜中,使用了选定食材的菜有 \(j\) 道,没有使用的有 \(k\) 道对应的方案数,这样求解答案的总时间复杂度为 \(O(n^3m)\)。由于我们只关心使用选定食材的菜数是否过半,所以可以将状态的后两维作差压为一维,总时间复杂度降至 \(O(n^2m)\)。
#include<bits/stdc++.h>
using namespace std;
const int N = 123;
const int M = 2345;
const int mod = 998244353;
void add(int& x, int y) {
x += y;
if (x >= mod) {
x -= mod;
}
}
void sub(int& x, int y) {
x -= y;
if (x < 0) {
x += mod;
}
}
int mul(int x, int y) {
return (long long)x * y % mod;
}
class my_array {
int a[M];
public:
void reset() {
memset(a, 0, sizeof a);
}
int& operator [] (int x) {
return a[(M >> 1) + x];
}
} f[2];
int n, m, a[N][M], s[N];
int dp(int ban) {
f[0].reset();
f[0][0] = 1;
for (int i = 1; i <= n; ++i) {
f[i & 1].reset();
int p = a[i][ban], other = (s[i] - a[i][ban] + mod) % mod;
for (int j = -i; j <= i; ++j) {
f[i & 1][j] = f[i - 1 & 1][j];
add(f[i & 1][j], mul(f[i - 1 & 1][j - 1], p));
add(f[i & 1][j], mul(f[i - 1 & 1][j + 1], other));
}
}
int result = 0;
for (int i = 1; i <= n; ++i) {
add(result, f[n & 1][i]);
}
return result;
}
int main() {
freopen("meal.in", "r", stdin);
freopen("meal.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> a[i][j];
add(s[i], a[i][j]);
}
}
int answer = 1;
for (int i = 1; i <= n; ++i) {
answer = mul(answer, s[i] + 1);
}
sub(answer, 1);
for (int i = 1; i <= m; ++i) {
sub(answer, dp(i));
}
cout << answer << '\n';
return 0;
}
E. 划分 / partition
设 \(f_i\) 为对前 \(i\) 个数进行划分得到的最小平方和,\(g_i\) 为对前 \(i\) 个数进行最优划分时倒数第二段的右端点位置(即最后一段对应的区间为 \((g_i, i]\)),那么有如下两条结论(记原序列 \(\{a_i\}\) 的前缀和为 \(\{s_i\}\)):
- \(f_i = \min\limits_{j < i} \{f_j + (s_i - s_j)^2\}\),其中位置 \(j\) 只需满足 \(s_i - s_j \geq s_j - s_{g_j}\),即一定将 \((g_j, j]\) 划为一段。
- 在结论 1 中,记满足 \(s_i - s_j \geq s_j - s_{g_j}\) 的 \(j\) 的最大值为 \(j'\),那么 \(j'\) 为 \(f_i\) 的最优转移点,即 \(g_i = j'\)。
对结论 1 的简要证明:
利用归纳法。假设我们已经求得了正确的 \(f_{1} \sim f_{i - 1}\) 与 \(g_{1} \sim g_{i - 1}\),现在将要求 \(f_i\)。对于前 \(i\) 个数,假设最优划分(记为划分方案 1)的最后一段为 \((j, i]\),倒数第二段为 \((k, j]\),倒数第三段为 \((l, k]\cdots\)
情况一:如果 \(s_i - s_j \geq s_j - s_{g_j}\),且 \(k \neq g_j\),那么已经求得的 \(g_j\) 不应是最优转移点,\(f_j\) 不应是最优答案,与假设矛盾。
情况二:如果 \(s_i - s_j < s_j - s_{g_j}\),假设前 \(j\) 个数的最优划分(记为划分方案 2)中,倒数的若干段分别为 \((k', j], (l', k'], \cdots\)(显然 \(k'\) 即为 \(g_j\)),那么有 \(j > k > k' > l > l' >\cdots\),即从位置 \(k\) 开始两种方案的划分位置呈交错分布(否则某种划分方案显然不优),交错情况最终会止于两种方案具有的某个相同的划分位置,再往前两种方案的划分位置则完全相同。
由于 \(s_j - s_k \leq s_i - s_j < s_j - s_{g_j}\),故 \(k > k' = g_j\)。若只考虑交错分布的这一段,那么假设方案 1 的划分段数为 \(m\),则方案 2 的划分段数为 \(m - 1\)。记方案 1 的 \(m\) 个区段的段内数和依次为 \(A_1, A_2, \cdots, A_m\),方案 2 的 \(m - 1\) 个区段的段内数和依次为 \(B_2, B_3, \cdots, B_m\),并设 \(B_1 = 0\)(显然 \(A, B\) 单调不下降)。那么我们总能不断地找到 \(p \in [2, m]\),满足 \(B_p > A_p\),且使 \(B_p\) 减去 \(1\),\(B_1\) 加上 \(1\) 后,\(B\) 仍然满足单调不下降,最终使得 \(A\) 与 \(B\) 两个数列完全相同。当更大的数减去 \(1\),更小的数加上 \(1\) 后,平方和一定会减小,所以方案 1 一定优于方案 2,故求得的 \(g_j\) 不应是最优转移点,\(f_j\) 不应是最优答案,也与假设矛盾。
故结论 1 正确。
利用结论 1 证明中对交错情况的分析,结论 2 也不难证得。
在两个结论的帮助下,使用单调队列可将 dp 求 \(f_n\) 的总时间复杂度优化到 \(O(n)\)。
最后三个测试点答案超过了 long long 的范围,需要使用高精度。注意到答案不会特别大,可以用两个 long long 类型的整数 \(a, b\) 来表示大整数 \((a \times 10^{18} + b)\),从而在一定程度上减小编码复杂度。
#include<bits/stdc++.h>
using namespace std;
const int N = 40000005;
const long long base = 1e18;
const long long pbase = 1e9;
const long long mod = 1 << 30;
int n, type, a[N], b[N], q[N], best[N];
long long s[N];
struct bigint_t {
long long foo, bar;
bigint_t() {
foo = bar = 0;
}
bigint_t operator + (const bigint_t& a) {
bigint_t result;
result.foo = foo + a.foo;
result.bar = bar + a.bar;
if (result.bar >= base) {
++result.foo;
result.bar -= base;
}
return result;
}
void print() {
if (foo) {
cout << foo;
cout << setw(18) << setfill('0') << bar;
} else {
cout << bar;
}
cout << '\n';
}
};
bigint_t square(long long a) {
long long x = a / pbase, y = a % pbase;
bigint_t result;
result.foo = x * x + 2 * x * y / pbase;
result.bar = 2 * x * y % pbase * pbase + y * y;
if (result.bar >= base) {
result.foo += result.bar / base;
result.bar %= base;
}
return result;
}
int main() {
freopen("partition.in", "r", stdin);
freopen("partition.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> type;
if (type == 0) {
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
} else {
int x, y, z, m, p, l, r;
cin >> x >> y >> z >> b[1] >> b[2] >> m;
int last = 0;
for (int i = 3; i <= n; ++i) {
b[i] = ((long long)x * b[i - 1] + (long long)y * b[i - 2] + z) & mod - 1;
}
for (int i = 1; i <= m; ++i) {
cin >> p >> l >> r;
for (int j = last + 1; j <= p; ++j) {
a[j] = l + (b[j] % (r - l + 1));
}
last = p;
}
}
for (int i = 1; i <= n; ++i) {
s[i] = s[i - 1] + a[i];
}
int l = 1, r = 1;
for (int i = 1; i <= n; ++i) {
auto get = [&] (int p) {
return (s[p] << 1) - s[best[p]];
};
while (l < r && s[i] >= get(q[l + 1])) {
++l;
}
best[i] = q[l];
while (l <= r && get(i) <= get(q[r])) {
--r;
}
q[++r] = i;
}
bigint_t answer;
int p = n;
while (p) {
answer = answer + square(s[p] - s[best[p]]);
p = best[p];
}
answer.print();
return 0;
}
F. 树的重心 / centroid
我最开始想算每个节点对答案的贡献,搞出了个巨难写的主席树做法,感觉又要花掉「树上的数」的调试时间,就学换了个做法。
对于一棵有根树,做树剖求出所有重链后,从根结点开始沿着重链向下走必然能走到重心(重心即使有两个,也都在该条重链上),且可以通过倍增将单次从根走至重心的时间优化到 \(O(\log n)\)。于是可以先将原树有根化(令 \(1\) 号节点为根),并预处理出所有重链。
假设删掉的边为 \((u, v)\)(\(u\) 为 \(v\) 的父节点),那么包含节点 \(v\) 的这一部分对应着原树一棵以 \(v\) 为根的子树,可以直接从 \(v\) 开始倍增找重心;对于包含节点 \(u\) 的这一部分,欲将其转化为以 \(u\) 为根的有根树,需要修改从 \(u\) 到 \(1\) 号节点这条链上的所有节点的信息,为此我们还需要额外记录每个节点的次重儿子。信息的修改与撤销可以在 dfs 与回溯的过程中完成。注意特判有两个重心的情况。
#include<bits/stdc++.h>
using namespace std;
const int N = 345678;
const int maxlog = 19;
int n, size[N], father[N], heavy[N][2], go[N][maxlog];
long long answer;
vector<int> adj[N];
void reset(int x) {
for (int i = 1; i < maxlog; ++i) {
go[x][i] = go[go[x][i - 1]][i - 1];
}
}
void dfs_init(int x, int f = 0) {
size[x] = 1;
heavy[x][0] = heavy[x][1] = 0;
int maxs = 0;
for (auto y : adj[x]) {
if (y != f) {
father[y] = x;
dfs_init(y, x);
size[x] += size[y];
if (size[y] > size[heavy[x][0]]) {
heavy[x][1] = heavy[x][0];
heavy[x][0] = y;
} else if (size[y] > size[heavy[x][1]]) {
heavy[x][1] = y;
}
}
}
go[x][0] = heavy[x][0];
reset(x);
}
void check(int x, int all) {
int start = x;
for (int i = maxlog - 1; ~i; --i) {
int y = go[x][i];
if (y && all - size[y] <= (all >> 1)) {
x = y;
}
}
answer += x;
if (x != start) {
int y = father[x];
if (size[x] <= (all >> 1) && all - size[y] <= (all >> 1)) {
answer += y;
}
}
}
void dfs(int x, int f = 0) {
if (f) {
check(x, size[x]);
check(f, size[f]);
}
for (auto y : adj[x]) {
if (y != f) {
father[x] = y;
int foobar = size[x];
size[x] = n - size[y];
if (y == heavy[x][0]) {
go[x][0] = size[f] > size[heavy[x][1]] ? f : heavy[x][1];
} else if (size[f] > size[heavy[x][0]]) {
go[x][0] = f;
}
reset(x);
dfs(y, x);
father[x] = f;
size[x] = foobar;
go[x][0] = heavy[x][0];
reset(x);
}
}
}
int main() {
freopen("centroid.in", "r", stdin);
freopen("centroid.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
int tt;
cin >> tt;
while (tt--) {
cin >> n;
for (int i = 1; i <= n; ++i) {
adj[i].clear();
}
for (int i = 1; i < n; ++i) {
int x, y;
cin >> x >> y;
adj[x].push_back(y);
adj[y].push_back(x);
}
dfs_init(1);
answer = 0;
dfs(1);
cout << answer << '\n';
}
return 0;
}