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(服务机编号全部小于客户机):该性质不影响解题,可按常规顺序处理。

正解

核心在于严格的地址合法性判断:

  1. 格式必须为 a.b.c.d:e,即 3 个点,1 个冒号,且冒号在最后一个点之后。
  2. 五个部分必须都是数字,且无前导零(单独 0 允许)。
  3. 数值范围: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> 分别存储所有桔子和苹果的位置。每轮操作如下:

  1. 选择两个集合中最小位置更小的那个作为本轮第一个取出的水果(颜色 v,位置 p)。
  2. 循环:
    • 在当前颜色 v 的集合中,用 lower_bound(p) 找到第一个大于等于 p 的位置(由于 p 刚被删除,实际是找该颜色中下一个位置)。
    • 若不存在则结束本轮。
    • 输出该位置,从集合中删除,并将 p 更新为该位置。
    • 切换颜色 v = 1 - v。
  3. 输出换行。

这样,每次恰好取出所有块的最左端,因为 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 第二轮的全部题解与代码实现,所有代码均通过了相应测试点的验证。希望这份详尽的报告能为您的博客提供有价值的参考。如有任何疑问,欢迎交流讨论。

posted @ 2026-08-29 20:26  surprise_ying  阅读(65)  评论(0)    收藏  举报