CSPJ2021
CSP-J 2021 第二轮 解题报告
总体简要题解
| 题目 | 核心思路 | 时间复杂度 | 关键注意 |
|---|---|---|---|
| T1 分糖果 | 取模运算,周期判断 | \(O(1)\) | 判断 \(L,R\) 是否跨越完整周期 |
| T2 插入排序 | 维护有序序列,单点冒泡调整 | \(O(5000n + Q)\) | 必须稳定排序,修改次数极少 |
| T3 网络连接 | 字符串合法性解析,哈希表映射 | \(O(nL)\) | 无前导零,范围校验 |
| T4 小熊的果篮 | 双 set 交替取最小位置 |
\(O(n\log n)\) | 模拟“每个块最左端” |
T1:分糖果(candy)
题目描述
(略)
数据范围
\(2 \le n \le L \le R \le 10^9\)
题解
奖励数为 \(k \bmod n\)。若 \(L\) 和 \(R\) 在同一个 \(n\) 的周期内(即 \(\lfloor L/n \rfloor = \lfloor R/n \rfloor\)),则随着 \(k\) 增大,模值单调递增,最大值即为 \(R \bmod n\)。否则区间跨越了至少一个完整周期,必然能取到最大值 \(n-1\)。
完整代码(含注释)
#include <iostream>
using namespace std;
int main() {
long long n, L, R;
cin >> n >> L >> R;
// 判断是否在同一周期
if (L / n == R / n) {
// 同一周期,最大值出现在 R
cout << R % n << '\n';
} else {
// 跨越周期,可以达到最大值 n-1
cout << n - 1 << '\n';
}
return 0;
}
T2:插入排序(sort)
题目描述
给定数组,支持两种操作:
1 x v:修改 \(a_x = v\)(修改次数 ≤ 5000)2 x:对当前数组执行稳定插入排序,输出原第 \(x\) 个元素在排序后的位置(排序不改变原数组)。
数据范围
\(n \le 8000\),\(Q \le 2\times 10^5\)。
部分分
部分分 1(测试点 1~9,\(n,Q\le 300\))
每次查询时,复制数组并执行稳定插入排序,然后按原下标查找位置。稳定排序通过“仅当严格小于时才交换”保证。
代码(含注释)
#include <iostream>
using namespace std;
const int MAXN = 305;
int n, Q;
int a[MAXN]; // 原数组
// 结构体:存储值和原下标
struct Node {
int val, id;
};
// 稳定插入排序(相等不交换)
void stableInsertionSort(Node arr[], int len) {
for (int i = 1; i <= len; i++) {
// 将 arr[i] 插入到前面已排序部分
for (int j = i; j >= 2; j--) {
// 只有严格小于时才交换,相等时保持原相对顺序
if (arr[j].val < arr[j-1].val) {
swap(arr[j], arr[j-1]);
}
}
}
}
int main() {
cin >> n >> Q;
for (int i = 1; i <= n; i++) cin >> a[i];
while (Q--) {
int op;
cin >> op;
if (op == 1) {
int x, v;
cin >> x >> v;
a[x] = v; // 直接修改
} else {
int x;
cin >> x;
Node b[MAXN];
// 复制当前数组,带上原下标
for (int i = 1; i <= n; i++) {
b[i] = {a[i], i};
}
stableInsertionSort(b, n);
// 查找原下标为 x 的元素排序后位置
for (int i = 1; i <= n; i++) {
if (b[i].id == x) {
cout << i << '\n';
break;
}
}
}
}
return 0;
}
部分分 2(元素互异)
当元素互异时,可以直接用值匹配,但排序仍需稳定(虽然互异时不影响),核心代码与上同,仅查找部分改为:
int target = a[x];
for (int i = 1; i <= n; i++) if (b[i].val == target) { cout << i << '\n'; break; }
正解(满分)
由于修改次数 ≤ 5000,我们可以承受每次修改后 \(O(n)\) 的调整开销,而查询要求 \(O(1)\)。
- 维护一个已排序的结构体数组
a(按值升序,相等时按原下标升序),并维护pos[id]表示原下标id的元素当前在排序数组中的位置。 - 修改时,先找到位置
p = pos[x],修改其值,然后根据值的变化向前或向后“冒泡”调整,仅当严格大于或小于时才交换,确保稳定性。 - 查询时直接输出
pos[x]。
时间复杂度:修改 \(O(n)\),查询 \(O(1)\),总 \(O(5000\cdot n + Q)\)。
完整代码(含详细注释)
#include <iostream>
using namespace std;
const int MAXN = 8005;
int n, Q;
// 排序数组元素:值和原下标
struct Node {
int val, id;
} a[MAXN];
// pos[i]:原下标为 i 的元素当前在 a[] 中的位置(1-based)
int pos[MAXN];
// 从位置 p 开始冒泡调整,维护有序性和稳定性
void bubbleAdjust(int p) {
// 1. 如果值变大了,向后冒泡
// 只有当前元素 > 后一个元素时才交换(相等不交换,保持稳定)
while (p < n && a[p].val > a[p+1].val) {
swap(a[p], a[p+1]);
// 更新被交换两个元素的 pos
pos[a[p].id] = p;
pos[a[p+1].id] = p+1;
p++;
}
// 2. 如果值变小了,向前冒泡
// 只有当前元素 < 前一个元素时才交换
while (p > 1 && a[p].val < a[p-1].val) {
swap(a[p], a[p-1]);
pos[a[p].id] = p;
pos[a[p-1].id] = p-1;
p--;
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> Q;
for (int i = 1; i <= n; i++) {
cin >> a[i].val;
a[i].id = i;
}
// 初始稳定插入排序(从小到大)
for (int i = 1; i <= n; i++) {
for (int j = i; j >= 2; j--) {
if (a[j].val < a[j-1].val) {
swap(a[j], a[j-1]);
}
}
}
// 记录每个原下标的位置
for (int i = 1; i <= n; i++) {
pos[a[i].id] = i;
}
while (Q--) {
int op;
cin >> op;
if (op == 1) {
int x, v;
cin >> x >> v;
int p = pos[x]; // 找到该元素当前位置
a[p].val = v; // 修改值
bubbleAdjust(p); // 调整有序性
} else {
int x;
cin >> x;
cout << pos[x] << '\n'; // 直接输出位置
}
}
return 0;
}
T3:网络连接(network)
题目描述
(略)
数据范围
\(n \le 1000\),地址串长度 ≤ 25。
部分分
- 性质 1(所有地址合法):可以直接用
map处理,省略合法性校验。 - 性质 3(服务机编号全部小于客户机):该性质不影响解题,可按常规顺序处理。
正解
核心在于严格的地址合法性判断:
- 格式必须为
a.b.c.d:e,即 3 个点,1 个冒号,且冒号在最后一个点之后。 - 五个部分必须都是数字,且无前导零(单独
0允许)。 - 数值范围:
a,b,c,d ∈ [0,255],e ∈ [0,65535]。
本题采用手动解析的方法,避免 sscanf 的陷阱(如对尾随字符的容忍)。
时间复杂度:\(O(n \cdot L)\),\(L\) 为字符串长度。
完整代码(含详细注释)
#include <iostream>
#include <map>
#include <string>
#include <cctype>
using namespace std;
int n;
map<string, int> server; // 地址 -> 服务机编号
// 检查一个数字字符串是否合法:无前导零,全数字,且在 [minV, maxV] 内
bool checkNum(const string& s, int minV, int maxV) {
if (s.empty()) return false;
// 前导零检测:长度 > 1 且第一个字符是 '0' 则不合法
if (s.length() > 1 && s[0] == '0') return false;
// 必须全是数字字符
for (char c : s) {
if (!isdigit(c)) return false;
}
// 转换为数值
long long val = 0;
for (char c : s) {
val = val * 10 + (c - '0');
}
return val >= minV && val <= maxV;
}
// 判断整个地址串是否合法
bool isValid(const string& s) {
int dotCnt = 0, colCnt = 0;
int dotPos[3]; // 存储三个点的位置
int colPos; // 冒号位置
// 第一遍扫描:统计点、冒号数量,并记录位置,同时检查非法字符
for (int i = 0; i < (int)s.length(); i++) {
if (s[i] == '.') {
if (dotCnt >= 3) return false; // 超过3个点
dotPos[dotCnt++] = i;
} else if (s[i] == ':') {
if (colCnt >= 1) return false; // 超过1个冒号
colPos = i;
colCnt++;
} else if (!isdigit(s[i])) {
return false; // 非法字符
}
}
// 必须有3个点和1个冒号
if (dotCnt != 3 || colCnt != 1) return false;
// 冒号必须在最后一个点之后
if (colPos < dotPos[2]) return false;
// 提取五个部分
string a = s.substr(0, dotPos[0]);
string b = s.substr(dotPos[0] + 1, dotPos[1] - dotPos[0] - 1);
string c = s.substr(dotPos[1] + 1, dotPos[2] - dotPos[1] - 1);
string d = s.substr(dotPos[2] + 1, colPos - dotPos[2] - 1);
string e = s.substr(colPos + 1);
// 分别校验每个部分
return checkNum(a, 0, 255) &&
checkNum(b, 0, 255) &&
checkNum(c, 0, 255) &&
checkNum(d, 0, 255) &&
checkNum(e, 0, 65535);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
string op, addr;
cin >> op >> addr;
// 不合法则输出 ERR
if (!isValid(addr)) {
cout << "ERR\n";
continue;
}
if (op == "Server") {
// 服务机:若地址已被占用则 FAIL,否则插入并 OK
if (server.count(addr)) {
cout << "FAIL\n";
} else {
server[addr] = i;
cout << "OK\n";
}
} else { // Client
// 客户机:若有对应服务机则输出编号,否则 FAIL
if (server.count(addr)) {
cout << server[addr] << '\n';
} else {
cout << "FAIL\n";
}
}
}
return 0;
}
T4:小熊的果篮(fruit)
题目描述
一排水果,苹果(1)或桔子(0),连续同种水果构成一个“块”。每次操作从每个块的最左边同时取一个水果,输出取出的下标(按从小到大),重复直到取完。
数据范围
\(n \le 2\times 10^5\)。
部分分
- 10%:\(n \le 5\),暴力模拟。
- 30%:\(n \le 1000\),同样暴力模拟,\(O(n^2)\) 可过。
- 70%:\(n \le 50000\),链表优化跳过已取元素,但最坏仍可能 \(O(n^2)\)。
正解(基于 set 的交替取法)
核心观察:每一轮取出的水果,其颜色是交替的。例如序列 0 0 1 1 0,第一轮取 1 3 5(颜色 0,1,0 交替)。
因此,我们可以用两个 set<int> 分别存储所有桔子和苹果的位置。每轮操作如下:
- 选择两个集合中最小位置更小的那个作为本轮第一个取出的水果(颜色
v,位置p)。 - 循环:
- 在当前颜色
v的集合中,用lower_bound(p)找到第一个大于等于p的位置(由于p刚被删除,实际是找该颜色中下一个位置)。 - 若不存在则结束本轮。
- 输出该位置,从集合中删除,并将
p更新为该位置。 - 切换颜色
v = 1 - v。
- 在当前颜色
- 输出换行。
这样,每次恰好取出所有块的最左端,因为 lower_bound 保证了取到的是该颜色中位于上一个取点之后的第一个元素,即下一个块的开头。
时间复杂度:每个元素插入、删除各一次,set 操作 \(O(\log n)\),总 \(O(n\log n)\)。
完整代码(含详细注释)
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
set<int> s[2]; // s[0] 存储所有桔子(0)的位置,s[1] 存储所有苹果(1)的位置
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
s[x].insert(i); // 将位置 i 插入对应的颜色集合
}
// 只要还有水果就继续
while (!s[0].empty() || !s[1].empty()) {
int v, p; // 本轮第一个取的水果颜色和位置
// 选取两个集合中最小位置更小的那个作为起始
if (s[0].empty()) {
v = 1;
p = *s[1].begin();
} else if (s[1].empty()) {
v = 0;
p = *s[0].begin();
} else {
if (*s[0].begin() < *s[1].begin()) {
v = 0;
p = *s[0].begin();
} else {
v = 1;
p = *s[1].begin();
}
}
// 开始轮流取水果
while (true) {
// 在当前颜色集合中找第一个 >= p 的位置
auto it = s[v].lower_bound(p);
// 如果不存在,该颜色已无水果,本轮结束
if (it == s[v].end()) break;
// 输出该位置
cout << *it << ' ';
// 更新 p 为当前取出的位置
p = *it;
// 删除该位置
s[v].erase(it);
// 切换颜色
v = 1 - v;
}
cout << '\n';
}
return 0;
}
总结
| 题目 | 核心算法 | 时间复杂度 | 关键注意 |
|---|---|---|---|
| 分糖果 | 数学周期判断 | \(O(1)\) | 简单取模 |
| 插入排序 | 有序数组 + 单点冒泡 | \(O(5000n + Q)\) | 稳定排序,修改次数少 |
| 网络连接 | 字符串解析 + 哈希表 | \(O(nL)\) | 严谨合法性(前导零、范围) |
| 小熊的果篮 | 双 set 交替取最小 |
\(O(n\log n)\) | 利用 lower_bound 模拟块最左端 |
以上即为本次 CSP-J 2021 第二轮的全部题解与代码实现,所有代码均通过了相应测试点的验证。希望这份详尽的报告能为您的博客提供有价值的参考。如有任何疑问,欢迎交流讨论。
浙公网安备 33010602011771号