202609CSP认证P3

202609CSP认证P3

1. 为什么 CSP T3 的模拟题容易写挂

CSP 第三题里经常会出现一类题:

  • 规则很多;
  • 单个规则并不难;
  • 数据范围也不大;
  • 看起来"照着题意模拟"就能做;
  • 但真正写代码时特别容易 WA。

这类题最难的地方通常不是算法复杂度,而是:

如何把题目的自然语言规则,转换成严格、无歧义的程序执行顺序。

这道"轮转调度"就是一个非常典型的例子。

题目里同时存在:

  • CPU 当前任务运行;
  • 时间片结束;
  • 任务完成;
  • 任务重新入队;
  • CPU 与队列映射变化;
  • CPU 从队列取新任务;
  • 偏好 CPU 导致处理速度变化。

每一个规则单独看都很简单,但是一旦多个事件发生在同一时刻,就必须严格按照题目规定的先后顺序处理。

这正是整道题的核心。


2. 第一步:不要急着写代码,先找"系统状态"

做模拟题的第一步,不应该是马上写 for 循环,而应该先问:

在任意一个时刻,我需要知道哪些信息,才能唯一确定系统下一步会发生什么?

对于这道题,状态主要分成三部分。

CPU 状态

每个 CPU 需要记录:

cpp 复制代码
struct cpu {
    int t;       // 时间片长度
    int idx;     // 当前对应哪个队列
    int status;  // 当前正在执行哪个任务,0 表示空闲
    int time;    // 当前任务已经连续运行了多久
};

其中最重要的是:

cpp 复制代码
idx

因为队列本身不动,变化的是:

CPU 与队列的对应关系。

这一点非常容易理解错。

并不是"任务队列整体移动",而是:

text 复制代码
CPU 1 原来处理队列 1
CPU 2 原来处理队列 2

轮转后:

CPU 1 处理队列 2
CPU 2 处理队列 1

因此,最自然的建模方式就是:

cpp 复制代码
x[i].idx

表示第 i 个 CPU 当前对应的队列。


任务状态

每个任务需要记录:

cpp 复制代码
struct work {
    int a;           // 当前剩余工作量
    set<int> k;      // 偏好 CPU 集合
    int begin;       // 第一次开始执行的时刻
    int end;         // 完成时刻
};

这里有一个非常重要的思想:

不需要保存"任务历史",只保存未来模拟所需的最小状态。

例如题目要求输出:

text 复制代码
第一次执行到最终完成经过多久

因此只需要:

cpp 复制代码
begin
end

最后输出:

cpp 复制代码
end - begin

即可。


队列状态

题目明确要求队列先进先出,因此最自然的数据结构:

cpp 复制代码
queue<int> q[N];

队列里存任务编号。

初始任务分配规则:

cpp 复制代码
q[(i - 1) % m + 1].push(i);

这一部分实际上就是按编号轮流分配。


3. 第二步:把题目中的"事件"全部列出来

模拟题最重要的习惯之一:

先枚举事件,再决定事件顺序。

这道题里,一个时刻可能发生:

  1. 某任务刚刚完成;
  2. 某任务时间片刚刚耗尽,需要重新入队;
  3. 系统到达 T 的倍数,CPU-队列映射轮转;
  4. 空闲 CPU 从当前对应队列取任务;
  5. CPU 运行 1ms。

如果没有把这些事件明确列出来,很容易把所有逻辑混在一个 for 循环中,最后出现边界错误。


4. 第三步:最关键------给每个时刻定义清楚含义

这是我这次最主要的错误来源。

一开始我写的是:

cpp 复制代码
for (int j = 1; j <= ...; j++)

然后默认:

text 复制代码
j 表示"第 j 毫秒"

这种写法非常危险。

因为你很快就会陷入:

  • j=2 是时刻 2?
  • 还是第二毫秒 [1,2)?
  • 在 j=2 时轮转,是第二毫秒之前还是之后?
  • 任务在第 2 毫秒结束,是在什么时候重新入队?

这些问题如果没有明确时间坐标,代码会产生大量 off-by-one 错误。

更好的定义是:

令 cur 表示绝对时刻。

例如:

text 复制代码
cur = 0

表示系统刚刚启动。

然后:

text 复制代码
[cur, cur+1)

表示 CPU 接下来运行 1ms。

因此:

text 复制代码
cur = 0:运行 [0,1)
cur = 1:运行 [1,2)
cur = 2:运行 [2,3)

如果:

text 复制代码
T = 2

那么真正轮转的时刻应该是:

text 复制代码
cur = 2,4,6,...

即:

cpp 复制代码
if (cur > 0 && cur % T == 0)

这样时间意义会非常清晰。


5. 第四步:严格确定"同一时刻事件顺序"

题目特意强调:

如果任务放回、全局轮转、取出新任务发生在同一时刻,则顺序为:

放回任务 → 全局轮转 → 取新任务。

因此对于任意时刻 cur,代码结构应该固定写成:

text 复制代码
① 处理刚刚结束的任务
② 全局轮转
③ CPU 取新任务
④ CPU 运行 1ms

这四步是整道题最核心的框架。

代码结构:

cpp 复制代码
for (int cur = 0; finished_cnt < n; cur++) {

    // 1. 处理上一毫秒结束的任务

    // 2. 执行全局轮转

    // 3. 空闲 CPU 取任务

    // 4. 所有 CPU 运行 1ms
}

这种写法比边模拟边判断稳定得多。


6. 为什么"放回 → 轮转 → 取出"这么重要

假设某个 CPU 当前对应队列 1。

某任务恰好在时刻 T 用完时间片。

此时题目要求:

text 复制代码
任务先回到队列 1
↓
CPU-队列映射轮转
↓
CPU 现在可能开始处理队列 2

注意:

任务回的是"轮转前的队列"。

而不是:

text 复制代码
先轮转
↓
任务被放到轮转后的队列

这两种情况会直接导致整个系统后续状态完全不同。

模拟题里这种错误非常常见:

事件本身没写错,但事件发生顺序写错。

而且往往样例不一定能充分暴露。


7. 我这道题中的第一个易错点:轮转发生早了 1ms

我一开始写的是:

cpp 复制代码
for (int j = 1; j <= ...; j++) {

    ...

    if (j % T == 0) {
        reverse();
    }

    // 接下来执行当前 1ms
}

表面看起来合理。

但是如果:

text 复制代码
T = 2

那么:

text 复制代码
j = 2

时就先执行了 reverse(),然后才执行第二毫秒。

这意味着实际过程变成:

text 复制代码
运行第 1ms
↓
轮转
↓
运行第 2ms

但正确过程是:

text 复制代码
运行第 1ms
↓
运行第 2ms
↓
轮转

也就是说:

轮转提前了整整 1ms。

解决方法就是:

cpp 复制代码
for (int cur = 0; ...)

并让 cur 真正表示时间点。


8. 我的第二个易错点:任务放回发生在下一轮

我最开始的代码大致结构是:

cpp 复制代码
// 当前这一毫秒运行
x[i].time++;
y[id].a -= ...

// 当前循环最后
if (j % T == 0)
    reverse();

然后任务是否时间片结束,是下一次循环开头才判断:

cpp 复制代码
if (check(i)) {
    q[x[i].idx].push(...);
}

这就会产生一个隐藏问题。

假设任务在时刻 T 刚好用完时间片。

我的代码实际顺序是:

text 复制代码
任务运行结束
↓
reverse()
↓
下一轮循环
↓
任务重新入队

于是重新入队时:

cpp 复制代码
x[i].idx

已经变成轮转后的队列。

但题目规定必须:

text 复制代码
放回
↓
轮转

因此任务进入了错误的队列。

这个问题非常值得积累,因为它属于模拟题里常见的一类:

状态更新被延迟到了下一轮,而中间又发生了其他全局事件。

以后看到类似问题一定要警惕。


9. 我的第三个易错点:空闲 CPU 也调用 check

原来的函数:

cpp 复制代码
bool check(int i){
    int assign = x[i].status;

    if (x[i].time == x[i].t)
        return true;

    if (y[assign].a <= 0)
        return true;

    return false;
}

问题是 CPU 空闲时:

cpp 复制代码
x[i].status == 0

于是:

cpp 复制代码
assign = 0

接下来访问:

cpp 复制代码
y[0].a

由于全局数组默认初始化为 0:

cpp 复制代码
y[0].a <= 0

成立。

所以逻辑上程序会认为:

text 复制代码
0 号任务完成了

虽然这次不一定直接导致最终答案错误,但这是明显的非法状态。

更推荐直接写:

cpp 复制代码
if (x[i].status == 0)
    continue;

然后再处理:

cpp 复制代码
if (y[id].a <= 0) {
    ...
}
else if (x[i].time == x[i].t) {
    ...
}

这样代码语义更直接,也更安全。


10. 一个重要经验:不要过度封装简单状态判断

我最初写了:

cpp 复制代码
bool check(int i)

想统一判断:

text 复制代码
任务完成
or
时间片耗尽

但这两个事件虽然都会导致 CPU 结束当前任务,它们后续处理完全不同:

text 复制代码
任务完成:
直接删除

时间片耗尽:
重新进入队列

因此把它们过早合并成一个 check(),反而会让逻辑复杂。

对于模拟题,经常应该遵守一个原则:

如果两个状态后续行为不同,就不要为了"代码短"强行合并判断。

直接写:

cpp 复制代码
if (完成) {
    ...
}
else if (时间片耗尽) {
    ...
}

通常更清楚。


11. 这道题推荐的完整模拟框架

核心代码结构:

cpp 复制代码
int finished_cnt = 0;

for (int cur = 0; finished_cnt < n; cur++) {

    // 1. 处理上一毫秒刚结束的任务
    for (int i = 1; i <= m; i++) {

        if (x[i].status == 0)
            continue;

        int id = x[i].status;

        if (y[id].a <= 0) {

            y[id].end = cur;

            x[i].status = 0;
            x[i].time = 0;

            finished_cnt++;
        }
        else if (x[i].time == x[i].t) {

            q[x[i].idx].push(id);

            x[i].status = 0;
            x[i].time = 0;
        }
    }

    // 2. 全局轮转
    if (cur > 0 && cur % T == 0) {
        reverse();
    }

    // 3. 空闲 CPU 取任务
    for (int i = 1; i <= m; i++) {

        if (x[i].status != 0)
            continue;

        if (q[x[i].idx].empty())
            continue;

        int id = q[x[i].idx].front();
        q[x[i].idx].pop();

        x[i].status = id;

        if (y[id].begin == -1) {
            y[id].begin = cur;
        }
    }

    // 4. CPU 运行一毫秒
    for (int i = 1; i <= m; i++) {

        if (x[i].status == 0)
            continue;

        int id = x[i].status;

        x[i].time++;

        if (y[id].k.count(i))
            y[id].a -= 2;
        else
            y[id].a -= 1;
    }
}

最后:

cpp 复制代码
cout << y[i].end - y[i].begin;

12. 这类 CSP 模拟题应该怎么想

以后做 CSP T3 模拟题,可以固定按照下面几个问题分析。

问题一:系统有哪些对象?

例如这道题:

text 复制代码
CPU
任务
队列

先分别定义状态。


问题二:哪些状态会变化?

例如:

text 复制代码
CPU 当前任务
CPU 对应队列
任务剩余工作量
任务所在队列
CPU 当前时间片使用量

注意不要保存无用信息。


问题三:系统中有哪些事件?

例如:

text 复制代码
任务完成
时间片结束
任务重新入队
轮转
取任务
运行

问题四:多个事件能否同时发生?

如果能,一定要继续问:

谁先谁后?

这一步经常就是整道题真正的考点。


问题五:循环变量到底代表什么?

不要模糊地写:

cpp 复制代码
for (int i = 1; ...)

然后脑子里想着"第 i 秒"。

必须明确:

text 复制代码
i 是时间点

还是:

text 复制代码
i 是第 i 个时间段

最好统一采用:

text 复制代码
cur = 当前绝对时刻

然后:

text 复制代码
[cur, cur+1)

表示一次模拟。

这样最稳定。


13. 模拟题一个非常实用的技巧:先写时间轴

如果题目涉及时间,正式写代码前建议先在草稿上写:

text 复制代码
t = 0
取任务
运行 [0,1)

t = 1
处理 [0,1) 的结果
取任务
运行 [1,2)

t = 2
处理 [1,2) 的结果
轮转
取任务
运行 [2,3)

只要这个时间轴能完全对应题意,代码基本不会错。

反过来,如果你连:

text 复制代码
t=2 到底先做什么

都说不清楚,就不要急着写代码。


14. 模拟题第二个实用技巧:把一轮拆成阶段

不要写成:

cpp 复制代码
for (cpu ...) {
    判断
    入队
    出队
    运行
    轮转
}

这种代码非常危险,因为不同 CPU 的操作顺序可能会互相干扰。

更好的方式是:

cpp 复制代码
// phase 1
for (所有 CPU)
    处理结束事件;

// phase 2
处理全局事件;

// phase 3
for (所有 CPU)
    取任务;

// phase 4
for (所有 CPU)
    执行任务;

也就是:

同类事件批量处理。

这是非常重要的模拟技巧。

因为题目描述的往往是:

"所有 CPU 在某个时刻同时发生某事"

而不是:

text 复制代码
CPU1 先完整操作一遍,
CPU2 再完整操作一遍。

15. 模拟题第三个实用技巧:区分局部事件和全局事件

这道题中:

局部事件:

text 复制代码
某 CPU 时间片耗尽
某任务完成
某 CPU 取任务

全局事件:

text 复制代码
所有 CPU 与队列的映射轮转

全局事件通常要单独拿出来处理:

cpp 复制代码
if (cur % T == 0) {
    ...
}

而不要塞进某一个 CPU 的循环里。


16. 模拟题第四个技巧:尽量让代码和题面顺序一致

如果题面写的是:

text 复制代码
放回
轮转
取出

代码最好也写:

cpp 复制代码
// 放回

// 轮转

// 取出

不要试图通过复杂判断把它们重新排列。

模拟题里:

代码越像题面,越不容易 WA。

这和算法题不一样。

很多算法题需要抽象和变形;

模拟题恰恰应该尽量少变形。


17. 数据范围分析:为什么可以逐毫秒模拟

这里:

text 复制代码
n,m <= 100
a_i <= 100
t_i <= 100

每个任务工作量最多 100。

任务总工作量最多:

text 复制代码
100 × 100 = 10000

即使考虑轮转、空闲、重新排队,整个模拟时间仍然不会达到特别大的数量级。

因此没有必要设计复杂的事件跳跃结构。

这种题先观察数据范围,如果时间足够小:

能暴力模拟就暴力模拟。

CSP T3 很多时候真正考的不是优化,而是:

text 复制代码
正确建模 + 边界处理

18. 我这次最值得积累的教训

这道题我最开始其实并不是不会模拟。

真正的问题是:

text 复制代码
我知道每个规则是什么意思,
但是没有给"时间"建立严格定义。

于是出现了:

  • 轮转提前 1ms;
  • 任务放回发生在轮转之后;
  • j 到底代表时间点还是第 j 毫秒不清晰。

所以以后遇到复杂模拟题,我应该优先问:

text 复制代码
循环开始时,当前系统处于什么时刻?
这一轮处理的是哪个时间区间?
循环结束后,系统处于什么时刻?

只要这三个问题回答清楚,大多数时间模拟问题都会简单很多。


19. 一套以后可以固定使用的 CSP 模拟题模板

以后每周做 CSP 第三题模拟题,可以按下面流程分析:

text 复制代码
1. 找对象
2. 列状态
3. 列事件
4. 找同时事件
5. 确定事件优先级
6. 定义循环变量含义
7. 写一小段时间轴
8. 分 phase 模拟
9. 手推极小样例
10. 再写代码

其中我认为最关键的是:

text 复制代码
事件顺序
时间定义
phase 分层

20. 总结

这道题表面上是在模拟 CPU 轮转调度,但真正值得学习的是复杂模拟题的通用处理方法。

我这次主要踩了三个坑:

text 复制代码
1. 没有严格定义时间坐标,导致轮转提前 1ms;
2. 任务放回被延迟到下一轮,导致先轮转后放回;
3. 空闲 CPU 也进入任务结束判断,出现 y[0] 这种非法状态。

以后遇到类似 CSP 模拟题,最重要的不是一上来写代码,而是先构造:

text 复制代码
状态
+
事件
+
事件顺序
+
时间轴

尤其是当题目出现:

text 复制代码
"若若干操作同时发生,则......"

这种描述时,基本可以直接判断:

这就是整道模拟题最值得关注的地方。

模拟题真正考察的往往不是"会不会模拟",而是能不能把自然语言规则严格翻译成一个确定的状态机。

这也是之后做 CSP T3 模拟题最应该重点训练的能力。

相关推荐
云淡风轻~窗明几净1 小时前
角谷猜想最接近严格证明的测试 Version1.1.1
人工智能·算法·机器学习·图论
机器学习之心2 小时前
PINN × SVM × NSGA-II × 熵权TOPSIS:一套跑通「建模—优化—决策」的多目标优化框架
算法·机器学习·支持向量机
别动我齐刘海2 小时前
机器人 C++ / 系统联调补强2——异常处理、Protobuf
linux·c++·人工智能·学习·算法·机器学习·机器人
荆棘鸟智能3 小时前
短历时洪水预报算法实践:水文模型与 LightGBM 残差订正融合
人工智能·算法·机器人
葫三生4 小时前
关于三生原理系列被技术开发社区批量收录或引发策展边界讨论的一种思考
大数据·人工智能·深度学习·算法·机器学习·数据挖掘·开源
catcatuncle4 小时前
OpenWorkBuddy 部署排错手册:从源码启动到自己搭一个给团队用,七个最容易卡住的地方
算法
avi91114 小时前
【AI教人】在Inspector显示所有Delegate-Odin Inspector之三
java·前端·算法·delegate·inspector·editor扩展·unity editor
淡海水4 小时前
06-02-源码-增量标记算法实现
算法·unity·c#·gc
碧海银沙音频科技研究院4 小时前
实现炬芯ATS308X的usb cdc 串口IAP 固件升级功能实现
人工智能·嵌入式硬件·深度学习·算法