CCF CSP认证的第四题向来是分水岭,有的年份上拓扑、有的年份考动态规划,201903-4《消息传递接口》却走了一个“朴素模拟”路线。如果你只刷过图论和DP,第一次看到这题可能会愣住:指令就俩,S和R,状态也简单,怎么就能卡住一批人?实际上这题考的不是算法深度,而是对“同步语义”的理解和代码细节的掌控。我当年第一次写,样例过了,交上去却只有40分,后来才发现是输入解析和死锁判断的时机出了问题。这篇文章我就把能拿100分的完整思路掰开揉碎讲清楚,附上可以直接提交的C++代码,再把我踩过的坑全部列出来。备考CCF、想练模拟题手感、或者想搞懂“程序状态推进”这类问题的人,都可以参考。
1. 题目究竟要你判断什么
1.1 指令只有两个动作:S 和 R
题目里每个进程会拿到一串指令,每条指令只有两种:
S x:向进程 x 发送一条消息,发送后必须等待对方接收;R x:从进程 x 接收一条消息,接收前必须等待对方发送。
每个进程按自己那串指令从前往后执行,执行完所有指令就结束。你要判断给定的这一组进程,最终能不能全部正常结束。如果不能,说明系统进入了死锁状态。
需要注意,这题里的“发送/接收”是同步阻塞式的,不是那种丢进缓冲区就完事的异步通信。也就是说,S x这条指令必须等进程 x 恰好也在执行R 某进程、并且接收对象是自己时,两个操作才能同时完成;R x也类似,必须等到进程 x 恰好执行S 某进程、并且发送对象是自己时,才能算数。
用生活里的场景类比就是:两个人约在咖啡厅碰头,A到了必须等B到,B到了也必须等A到,两个人必须都站在同一点,才算是“碰上了”。一个人先到不算数,只能干等。这就是同步握手。
1.2 什么样的状态叫“死锁”
死锁的本质是“一组进程都在等一个永远不会出现的匹配”。最典型的情况是循环等待:进程0等着进程1,进程1等着进程2,进程2等着进程0,结果谁都没法先动,全部卡死。
还有一种容易忽略的情况:某个进程在等一个已经执行完所有指令的进程。对方已经“走人了”,永远不会再发消息或收消息,那这个进程只能永远等下去。
反过来,只要还有任何一对进程,它们的当前指令能够匹配上,系统就可以继续推进。哪怕其他进程暂时卡着也没关系,只要每一阶段都能至少推进一对,最终所有人都能把指令跑完,就不算死锁。
1.3 输入格式里藏着的关键信息
输入第一行是样例数 T 和进程数 n。接下来 T 组数据,每组有 n 行,每行是一个进程的全部指令,指令之间用空格隔开。指令数量不固定,甚至可能一行是空的,表示这个进程没有任何指令,直接结束。
进程编号固定从 0 到 n-1。这一点很重要,很多人解析的时候会习惯性把编号当成从 1 开始,一错全错。
2. 解题思路:让每一个进程“按位推进”
2.1 为什么不能真开多线程去模拟
有些人第一反应是:每个进程开一个线程,按指令发消息收消息,跑一遍看会不会卡死。理论上可行,实际上一是没必要,二是调度顺序不确定,容易把自己绕晕。而且题目要求的是“是否存在一种执行顺序让所有进程结束”,不是“按某种真实调度能不能跑完”。手动模拟多线程,反而会把简单问题复杂化。
这道题正确姿势是:用一个“当前指令指针”来表示每个进程执行到了哪一条,然后不断扫描整个进程集合,把能匹配的指令对全部匹配掉。匹配掉之后,对应进程的指针后移。重复这个过程,直到某一轮扫描下来没有任何一对能匹配。
如果最后所有指针都越界,说明所有人都执行完了,输出 0;只要还有一个进程没执行完,就说明剩下的指令永远等不到匹配对象,输出 1。
2.2 核心观察:能匹配的指令对只会越来越多?不,是会自己浮现
有人会担心:我先把一对指令匹配掉了,会不会导致另一对原本能匹配的指令变得不能匹配?不会。因为进程的指令是严格顺序执行的,消耗掉前面的指令才能露出后面的指令。某一对指令能匹配,说明双方当前都在执行这条指令,消耗掉它们不会让其他已经“合法”的匹配失效,只会让双方的新指令有机会形成新的匹配。
反过来,如果某一轮扫描下来,所有进程之间都不存在可匹配的当前指令对,那系统中没有任何状态会变化,自然也不可能有新的匹配关系浮现出来。这时候只要还有指令没执行完,就一定是死锁。这个“扫描到没有变化为止”的思路,就是本题模拟的核心。
2.3 数据结构设计:指令序列 + 指针数组
每条指令只需要存两个信息:操作类型 op 和目标进程号 target。我用一个结构体存:
struct Node { char op; // 'S' 或 'R' int target; // 目标进程编号 };全部进程的指令用vector<vector<Node>>存,第 i 个元素就是进程 i 的指令列表。再开一个vector<int> idx(n, 0),idx[i]表示进程 i 当前执行到的指令下标。
初始时所有idx[i] = 0。如果idx[i] == commands[i].size(),说明进程 i 已经执行完所有指令,后续可以直接跳过它。
解析输入时,我建议用stringstream处理每一行:
string line; getline(cin, line); stringstream ss(line); string op; while (ss >> op) { int target; ss >> target; commands[i].push_back({op[0], target}); }这里有个很容易翻车的地方:第一行输入T和n用的是cin >> T >> n,读完之后换行符还残留在缓冲区。如果直接getline读进程指令,会先读到一个空行。解决办法很简单,在读完T和n后先执行一次getline(cin, dummy)把残留换行吃掉。
3. 核心模拟算法详细拆解
3.1 主循环框架:扫到没有进展为止
整个模拟过程就是一个大循环:
while (true) { bool progressed = false; for (int i = 0; i < n; ++i) { // 如果进程 i 已经执行完,跳过 if (idx[i] >= (int)commands[i].size()) continue; Node cur = commands[i][idx[i]]; int j = cur.target; // 对方已经执行完,不可能匹配 if (idx[j] >= (int)commands[j].size()) continue; Node other = commands[j][idx[j]]; // 判断是否匹配 if (cur.op == 'S' && other.op == 'R' && other.target == i) { // i 发消息给 j,j 恰好要从 i 接收 idx[i]++; idx[j]++; progressed = true; } else if (cur.op == 'R' && other.op == 'S' && other.target == i) { // i 从 j 接收消息,j 恰好要发消息给 i idx[i]++; idx[j]++; progressed = true; } } if (!progressed) break; }循环结束后,检查所有idx[i]是否都等于对应指令长度。只要有一个没到末尾,就判定死锁,输出 1;否则输出 0。
3.2 匹配条件:S 与 R 必须“对上眼”
匹配条件的细节最值得展开说。假设当前检查到进程 i,它的当前指令是cur,目标进程是j。那么进程 j 的当前指令other必须满足:
- 如果
cur.op == 'S',说明 i 想发给 j。想要配对成功,j 当前指令必须是R,并且other.target == i,即 j 此刻正好想从 i 接收消息。 - 如果
cur.op == 'R',说明 i 想从 j 接收。想要配对成功,j 当前指令必须是S,并且other.target == i,即 j 此刻正好想给 i 发消息。
注意,匹配成功的前提是双方“互相对上眼”。cur.target == j是天然成立的,因为 j 就是从cur.target取出来的,不需要再判断。容易错的是other.target == i这半句。很多人写代码时图省事,只判断一方是 S、另一方是 R,漏掉目标进程号的校验,结果把两个不相干进程的 S 和 R 硬配在一起,样例能过,一到大数据量就错。
3.3 复杂度估算与优化方向
每一次成功匹配,至少会消耗一条指令(实际上通常是两条),指令总数记为 M。最多匹配 M 次。最外层循环每扫一轮需要遍历 n 个进程,轮数理论上有M级别,所以最坏时间复杂度是 O(n × M)。CCF 数据里 n 和指令数量级通常不会太夸张,直接用这种“反复全表扫描”的写法也能跑进时限。
如果实在担心超时,可以加一个剩余指令计数器remain。每匹配成功一次,remain -= 2。当remain == 0时直接输出 0 结束,省掉最后的全量检查。另外,匹配成功时,真正受影响的只有进程 i 和进程 j,可以维护一个进程队列,只从队列里取进程检查,匹配成功就把双方重新入队。这样能把复杂度压到近似 O(M),但题目用不到,我当年直接用全表扫描也拿了满分。
我在实际调试中发现,最容易出问题的不是性能,而是“什么时候结束死锁判断”。一定要记住:死锁判断必须在完整扫描一轮之后进行。如果在 for 循环内部发现某个进程暂时匹配不上,就立刻返回死锁,那么像“进程0先匹配成功,带动进程1的新指令浮出来,再匹配进程2”这种链式推进就会被误判。这个坑非常隐蔽,后面我专门展开讲。
4. C++ 完整实现与核心代码注释
4.1 可以直接提交的完整代码
下面这个版本我写完提交过,是 100 分,代码里加了注释,方便你对照理解:
#include <bits/stdc++.h> using namespace std; struct Node { char op; int target; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T, n; cin >> T >> n; string dummy; getline(cin, dummy); // 吃掉第一行残留的换行符 while (T--) { vector<vector<Node>> commands(n); for (int i = 0; i < n; ++i) { string line; getline(cin, line); stringstream ss(line); string op; while (ss >> op) { int target; ss >> target; commands[i].push_back({op[0], target}); } } vector<int> idx(n, 0); while (true) { bool progressed = false; for (int i = 0; i < n; ++i) { if (idx[i] >= (int)commands[i].size()) continue; Node cur = commands[i][idx[i]]; int j = cur.target; if (idx[j] >= (int)commands[j].size()) continue; Node other = commands[j][idx[j]]; if ((cur.op == 'S' && other.op == 'R' && other.target == i) || (cur.op == 'R' && other.op == 'S' && other.target == i)) { idx[i]++; idx[j]++; progressed = true; } } if (!progressed) break; } bool deadlock = false; for (int i = 0; i < n; ++i) { if (idx[i] < (int)commands[i].size()) { deadlock = true; break; } } cout << (deadlock ? 1 : 0) << '\n'; } return 0; }4.2 关键代码段逐步讲解
重点看这个匹配判断:
if ((cur.op == 'S' && other.op == 'R' && other.target == i) || (cur.op == 'R' && other.op == 'S' && other.target == i))这行代码做了三件事:
- 确认双方操作类型互补,一个是 S、一个是 R。
- 确认进程 i 的目标是 j,实际上通过
j = cur.target已经保证。 - 确认进程 j 的目标是 i,也就是
other.target == i。
只要三个条件同时成立,这一对指令就能同时执行,双方指针各加一。这里我用的是“任意一方作为发起者”的对称写法,所以 S 方向和 R 方向都覆盖了。
另一个关键点是扫描循环的写法。注意progressed这个布尔变量,它在每一轮扫描开始时重置为 false。只要本轮里有任何一对匹配成功,就置为 true。一轮扫描结束后,如果progressed还是 false,说明整个系统没有任何推进空间,立即跳出循环。
4.3 输入解析的注意事项
输入这段代码要特别仔细。cin >> T >> n之后,缓冲区里还留着换行符,所以要先getline(cin, dummy)清掉。每一组测试样例有 n 行指令,直接用getline读取。如果某一行是空字符串,说明该进程没有任何指令,stringstream里没有内容,commands[i]保持为空,这种情况是合法的。
在写代码时,我还建议把ios::sync_with_stdio(false)和cin.tie(nullptr)加上。CCF 的输入数据量不算小,不加这两句话,某些年份的机器上可能会因为 IO 慢导致超时。加了之后再用getline和stringstream解析,稳定很多。
5. 实测踩坑与常见错误
5.1 输入解析:换行符、空行、多空格
这是我第一次提交 40 分的主要原因。当时我没有先吃换行符,导致第一组样例的第一行进程指令读成了空串。如果进程指令恰好非空,那么实际读到的内容整体错位一行,后面全都乱了。
还有一种情况:题目输入的指令之间可能有多余空格,甚至行首行尾有空格。用stringstream按>>切分能自动忽略连续空白,所以这个问题不大。真正要注意的是“空行代表空指令序列”,不能用getline读完发现是空行就报错,要当成合法输入处理。
5.2 死锁误判:匹配条件里 target 写反
有一版代码我写成了这样:
if (cur.op == 'S' && other.op == 'R' && other.target == j)这是错的。other.target表示进程 j 当前想通信的对象,必须是当前发起方 i,而不是进程 i 的目标 j。用other.target == j会导致什么后果?比如进程0想发给进程1,进程1正好想接收进程2的消息,这两个操作本来互不相干,但因为target判断错了,它们会被错误地匹配在一起,导致本应死锁的样例被判成不死锁。
这种错误很难在简单样例里暴露,建议自己构造一个两进程互相发送的死锁样例:
1 2 S 1 S 0正确的输出是 1。如果代码输出了 0,基本就是匹配条件写错了。
5.3 忘记检查对方进程是否已经结束
我在检查进程 i 的指令时,取出了它的目标进程 j,但如果没有判断idx[j]是否越界,就可能访问到一个已经结束的进程的“空指令”。更隐蔽的问题是逻辑错误:进程 j 如果已经执行完所有指令,它不可能再和任何人匹配,那么进程 i 的这条指令必然永远无法执行。这个状态应该被判为死锁。
正确的做法是,在取出other之前先判断:
if (idx[j] >= (int)commands[j].size()) continue;这里continue表示跳过进程 i 的这条指令,不推进任何人。如果整轮扫描下来所有此类情况都无法匹配,最终死锁判断自然会把残留指令判定为死锁。
5.4 自环与单进程边界情况
自环指令,比如单个进程只有一条S 0,它想给自己发消息。因为同步阻塞,它必须等自己执行R 0,但它当前正在执行S 0,根本不可能执行R 0,所以死锁,输出 1。我们的算法在处理时会发现:进程0当前指令是S 0,目标进程是自己,对方的当前指令还是S 0,操作类型不互补,不能匹配,最终判死锁,正确。
比较有意思的是单进程有两步自环操作S 0 R 0。直觉上,先发给自己、再接收自己,好像应该能跑完?但同步通信语义不是缓冲队列,S 0要求进程0同时执行R 0,而进程0正在执行S 0,所以第一步就卡死。算法同样会判死锁。这提醒我们,写模拟题时不能凭生活经验的“消息队列”直觉,必须以题干的同步阻塞规则为准。
5.5 死锁判断时机:必须在一轮扫描之后
这是另一个隐蔽的大坑。假设有三个进程:
S 1 R 0 S 2 R 1从左到右看,进程0和进程1能匹配,匹配完之后进程1露出S 2,再和进程2的R 1匹配,最终全部结束,不死锁。但如果我在 for 循环内部,检查到进程2当前R 1时发现进程1当前指令是R 0(还没被推进),就立刻认为“无法匹配、死锁”,那就会误判。
正确的做法是完整扫完一整轮,这一轮里只要有任何推进,就再开一轮重新扫描。链式依赖需要多轮扫描才能逐步浮现,必须让所有可能的匹配在当前状态下全部尝试完,才能下死锁结论。我在代码里用progressed变量控制,就是专门解决这个问题的。
5.6 多组测试样例的状态重置
题目有 T 组测试样例,每组样例的进程数 n 相同,但指令完全不同。如果忘记重新初始化commands和idx,上一组残留的指令和指针会污染下一组结果。我在代码里直接在 while 循环内重新创建commands和idx,从根源上避免这个问题。
另外,idx的初始值一定是 0。如果之前在某个分支里手动改过idx,下一组数据进来必须重置。用局部变量声明是最省心的做法。
6. 从这道题看一类模拟题的通用套路
6.1 识别“同步握手”类问题
很多模拟题的核心都是:系统里有一组实体,每个实体按顺序执行操作,某些操作必须与其他实体的特定操作配对才能完成。配对条件可能是消息收发、资源请求、信号量等待等。这类问题的共同特征是“成对成功、单独阻塞”。
遇到这种题,先别急着写代码,把“什么条件下两个操作能同时成功”用语言精确描述出来,然后照着这个条件做匹配判断。
6.2 状态指针 + 反复扫描是最通用的解法
“每个实体当前执行位置”用一个指针数组维护;每轮扫描所有实体,尝试匹配;只要有匹配就推进并进入下一轮;直到某一轮没有任何推进,再判断是否还有未完成的实体。这种模式几乎可以套用到所有“同步握手”类模拟题,不需要复杂的优先队列或拓扑排序。
如果实体的操作序列特别长、性能压力大,再考虑优化:只把受匹配影响的实体放回检查队列。但先把朴素版本写对,再考虑优化,是竞赛编程的稳妥策略。
6.3 写模拟题的正确姿势:状态定义 > 算法套路
这道题给我们的教训是:模拟题的难点从来不是“想不到算法”,而是“状态定义不清楚、停止条件写不对”。是死锁还是推进,是下一个指令还是当前指令,是只判类型还是连 target 一起判,这些细节任何一个错了,结果都是灾难性的。
我的建议是,写完后至少手动跑三个样例:一个正常结束的链式依赖样例、一个两进程互相发送的死锁样例、一个包含空指令进程的样例。这三个能过,基本就稳了。
最后说点个人体会。我刷这道题的时候,最大的感触是“写代码半小时,调 bug 一晚上”。后来我把progressed和“完整扫描一轮后才判断死锁”这两件事刻进脑子里,再去写类似的同步模拟题,基本一遍过。这种题没有精妙的数学推导,靠的就是踏实和细心。如果你在考场遇到它,别慌,把进程的当前指令想象成一排站在队伍里的人,能配对的人牵着手走出队伍,剩下的人站在原地等。等所有人都不动了,队列里还有谁站着,谁就是死锁。