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. 第二步:把题目中的"事件"全部列出来
模拟题最重要的习惯之一:
先枚举事件,再决定事件顺序。
这道题里,一个时刻可能发生:
- 某任务刚刚完成;
- 某任务时间片刚刚耗尽,需要重新入队;
- 系统到达
T的倍数,CPU-队列映射轮转; - 空闲 CPU 从当前对应队列取任务;
- 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 模拟题最应该重点训练的能力。