1. 拿到题先别写代码:这份作息表本质是求时间轴的补集
PTA 上这道《老板的作息表》,我第一次看题面的时候扫了一眼就动手写了,结果交了四发才过。原因很简单:这道题的题干看起来像“格式化输出练习”,实际上是一道包装在时间字符串外衣下的区间合并与补集问题。它要求你在一整天 00:00:00 到 23:59:59 的时间轴上,把输入给出的若干段“已占用”的时间挖掉,再把剩下的空档按时间顺序输出出来。输入是“老板什么时候在忙”,输出是“老板什么时候空着”,方向刚好是反的。
这类题在 PTA 的 L1、L2 区间里出现频率不低,难度标注往往不高,但通过率经常不理想。因为这些题看着简单,扣分点全藏在细节里:时间要转成什么单位比较、区间首尾相接算不算空档、一天的结束点到底是 23:59:59 还是 24:00:00、输入行的分隔符空格数量不固定怎么办。每一个点单独拿出来都不难,几个叠在一起就很容易让人在自测通过、提交 WA(Wrong Answer)之间反复横跳。
适合看这篇的人,大概分三类:正在刷 PTA 题库、被这道题卡住的同学;想系统梳理“时间区间”这一大类题目的过题人;以及带别人入门算法、需要一份能讲清楚“为什么这么写”的讲解材料的朋友。我下面会从题面结构、时间换算、扫描逻辑、代码实现、调试套路五个层面拆开讲,而且每个关键选择我都会说清楚为什么这么做、不这么做会出什么事,尽量让你看完之后遇到同类题能直接套思路,而不是只能抄一段代码去交。
提醒:不同场次、不同题库版本里,这道题的输出终点可能存在细微差异(有的要求输出到 23:59:59,有的覆盖到 24:00:00,还有的会额外要求输出总时长)。下面讲解以“半开区间覆盖到一天结束”这个最常见版本为主线,具体边界请以你手上那份题面为准,但方法完全通用。
1.1 题面结构拆解:输入是“已占用”,要的是“未占用”
把题干翻译成数据语言,其实是这么一件事:给若干个形如hh:mm:ss - hh:mm:ss的闭区间,它们彼此不重叠(题面通常会保证这一点,但不一定保证有序),它们共同覆盖了时间轴上的一部分位置。你需要找出时间轴上所有没有被覆盖的位置,并且把这些位置按时间顺序合并成尽量少、尽量长的连续段,最后每一段输出一行。
这里的关键词是“合并成连续段”。如果输入是08:00:00 - 09:00:00和09:00:00 - 10:00:00,那么这两段是首尾相接的,中间没有留下任何空档,你不应该输出09:00:00 - 09:00:00这种长度为零的鬼东西。很多人的第一版代码就栽在这儿——忘了判断“空档长度必须大于零”,于是输出了大量零长度的区间。
再往深想一层,这道题真正要求你建立的心智模型是:时间轴是一条从 0 开始、到某一点结束的一维直线,输入给你的是若干条已经涂了颜色的线段,你要输出所有没涂颜色的线段。建立这个模型之后,剩下的事情就是把时间换算成可以比较和相减的数字,然后做一次从左到右的清扫。跟字符串、跟日期、跟业务逻辑都没有关系。
1.2 为什么纯字符串拼接一定会在边界翻车
有人会想:时间字符串格式固定,字典序不就是时间序吗,那我直接比较字符串行不行?答案是——在“严格补零、格式统一”的前提下,确实行,"08:30:00" < "09:00:00"成立,"23:59:59" > "00:00:00"也成立。这是定长补零带来的一个很舒服的性质,很多老手也会顺手利用。
但问题出在两个地方。第一,你没法用字符串做出“两段时间相差多少”这种减法,而这道题里恰恰需要判断空档长度是否为正、需要把当前覆盖到哪儿往前推。第二,一旦输入里混进不补零的写法(比如某个用例的时是9:05:03而不是09:05:03),字典序立刻崩盘:"9:05:03"会排在"10:00:00"后面,因为字符'9'的编码大于'1'。你不可能在题面里找到“保证补零”的明确承诺,所以这个假设一旦不成立,就是整题崩。
我的结论很直接:别在这道题上炫技巧,老老实实转成整数秒。整数天生可比较、可相减、可排序,格式化问题留到最后一步再解决。看起来多写了几行,实际上省掉了后面无数种诡异的边界情况。
1.3 三个必须先确认的题面细节
在动手之前,我习惯先把三个细节在纸上写清楚,否则写完还得返工。
第一个是一天的起始和结束。绝大多数版本是从00:00:00开始,结束点有两种写法:23:59:59或者24:00:00。这两者的差别在于你怎么定义“覆盖”。如果用左闭右开区间[0, 86400)来描述一整天,那么86400这个数就代表一天结束的那一刻,输出的时候需要把它格式化成24:00:00或者特殊处理成23:59:59。如果题面明确要求最大到23:59:59,那你就得把上界设成86399,并且在最后一段用闭区间处理,稍微麻烦一丁点。
第二个是输入行里分隔符周围的空格。题面给的样例通常是08:00:00 - 09:00:00,中间是“空格-短横-空格”。但真实用例里可能只有一个空格,也可能有多个,也可能没有。这时候如果你用getline读到一行再按固定位置切分,只要少一个空格就全错。后面我会给一个用格式化输入自动吞掉空白的写法,能省掉这一类麻烦。
第三个是是否存在一整天都没有任何安排的情况。这种时候n可能是 0,或者输入里只有一段覆盖全天的记录。前者的程序如果没处理空输入,很容易在读入阶段就崩掉,或者什么都不输出(而正确答案应该是输出一整天)。
2. 时间与秒的互转:整道题最容易被低估的一段
这一层代码量最少,但出问题的概率最高。我见过太多人主逻辑写得漂漂亮亮,最后卡在%02d少写了一个 0,或者s % 60和s % 3600用混,输出变成8:0:0这种格式。时间换算的逻辑本身极其简单,难的是把所有分支都覆盖到、把格式写到和题面一模一样。
我通常把这一层单独封装成两个函数:一个负责把时:分:秒解析成从零点开始的秒数,一个负责把秒数格式化回字符串。封装的目的是让主逻辑里不出现任何*3600、/60这种魔法数字,读代码的时候不会被这些常数打断思路。这个习惯在打比赛的时候尤其值钱,因为时间紧,主逻辑一乱就得重写。
2.1 为什么我坚持统一转成秒
用秒做内部表示,有几个很实在的好处。第一,比较和排序直接就是整数比较,不用写任何自定义比较函数。第二,区间的“长度”有了明确含义,r - l大于零就代表有覆盖,等于零就代表零长度,逻辑上非常干净。第三,区间的前后关系可以直接用<、>判断,不需要知道具体是几点几分。
还有一个容易被忽略的好处:用秒做单位,可以把“包含”和“相交”统一成一种处理。假设当前已经覆盖到cur秒,新来一个区间[l, r)。如果l > cur,说明中间有空档,输出[cur, l);然后cur = max(cur, r)。这两行代码就同时处理了完全不相交、部分重叠、完全被包含三种情况,不需要写三个if。这就是选对表示方式带来的红利。
顺手算一下量级:一天是 24 小时,也就是 86400 秒。这个数量级用int完全够(32 位有符号整数上限约 21 亿),甚至用short都勉强能装下,但没必要省这点空间。如果题目扩展到多天,比如把日期也带上,那就要换成更大的单位(比如从某个纪元开始的秒数),32 位整数在跨越几十年之后就会溢出,那时候得用 64 位。这道题不用担心,但心里有这个数,遇到“跨天作息表”的变种题就不会翻车。
2.2 解析函数里吞掉空格的写法
C++ 里最省事的方式是用scanf或者cin配合格式串,让输入函数自己跳过空白:
int h1, m1, s1, h2, m2, s2; scanf("%d:%d:%d - %d:%d:%d", &h1, &m1, &s1, &h2, &m2, &s2);格式串里%d前面的空格本身不严格要求匹配一个空格,%d在读取整数之前会自动跳过任意数量的空白字符(包括零个)。也就是说,-后面的空格数量对结果基本没有影响。这一点非常实用,能挡掉“分隔符周围空格不固定”的一整类坑。
如果你用cin,注意cin >> h1读完之后流里还留着:,直接cin >> m1会失败,因为这个冒号不是数字。必须写成cin >> h1然后cin.ignore()吃掉冒号,或者用scanf。这也是为什么我在这类题上基本不用cin >>直接读时间,太啰嗦。
还有一种是getline读整行再手动切分,思路是把行内所有非数字字符替换成空格,再用stringstream逐个读数字。这种写法通用性最强,能处理任何奇怪的分隔符,但代码量和常数都更大。我一般在题目格式明确的时候用scanf,格式含糊的时候才上stringstream。
2.3 格式化输出的补零与终点值
输出阶段最容易出两种错:补零漏了,以及终点值算错。
补零的标准写法是%02d,表示至少两位、不足两位用 0 填充。三个字段都用这个格式,输出就一定是hh:mm:ss。千万别用%d再手动判断是否小于 10,那种写法又长又容易漏分支。
终点值的问题在于:当你用半开区间[0, 86400)表示一整天的时候,86400这个数字本身是“第二天的零点”,并不属于今天。所以如果最后一段空档延伸到这一天结束,你需要决定输出成什么。最稳妥的做法是看题面样例:如果样例里出现过23:59:59,那就把上界设成86399并且在最后一段用闭区间;如果样例里出现的是24:00:00,那就保持86400直接格式化。我的经验是,大部分版本输出的是23:59:59,但样例里没出现过的边界千万不要靠猜,宁可自己造一个“一整天全空”的用例,看看题目期望什么。
string fmt(int sec) { char buf[16]; int h = sec / 3600; int m = sec % 3600 / 60; int s = sec % 60; snprintf(buf, sizeof(buf), "%02d:%02d:%02d", h, m, s); return string(buf); }这段代码我几乎是背下来的,因为它在这类题里会重复出现无数次。注意sec % 3600 / 60这个写法,先取余再除,等价于“去掉小时之后剩下的秒数里有多少个整分钟”,比(sec / 60) % 60少一次除法,也更直观一点。
3. 排序后再扫一遍:把散乱区间缝成一条覆盖线
主逻辑部分其实只有十几行,但每一行都有它的理由。我见过很多人写法上是“对每个区间,去找它前面的所有区间看看有没有重叠”,写出来是双重循环,逻辑复杂还容易漏情况。正确的思路是先把区间按起点排序,然后从左往右单向扫一遍,维护一个“目前已经覆盖到哪儿”的指针。这样每个区间只看一次,代码短、思路清晰、不会漏。
这个套路不只是这道题能用。合并区间、会议室安排、区间覆盖问题、日程冲突检测,本质都是同一件事。把这套“排序 + 单指针推进”的模板吃透,后面遇到一堆同类题都能直接套。我在带别人过题的时候,一般会让他们先把这套模板手写三遍,写到不看笔记也能一次写对为止。
3.1 输入顺序不可信时的处理
题目给样例的时候,输入通常是有序的,这会给人一个错觉:好像不用排序。但题面里绝大多数情况下不会承诺输入有序,而且很多题目的隐藏用例就是专门拿乱序输入来卡人的。我就吃过这个亏:本地测了三组样例全过,提交直接 WA,把输入打出来一看,最后一行的时间比第一行还早。
所以排序这一步不能省。C++ 里vector<pair<int,int>>默认的sort就是按pair的字典序比较,先比第一个元素再比第二个,而这正好就是“按起点升序、起点相同按终点升序”,完全符合需要。也就是说,你根本不用自己写比较函数,sort(segs.begin(), segs.end())一行就够了。
如果用的是结构体或者自定义类型,那就得写一个operator<或者传一个 lambda。我一般选 lambda,写起来短:
sort(segs.begin(), segs.end(), [](const pair<int,int>& a, const pair<int,int>& b) { return a.first < b.first; });这里只按起点排就够了,终点顺序无所谓,因为后面用max处理。不过按pair默认排序也不会有任何副作用,所以我不太纠结这个细节。
3.2 用“当前覆盖到的位置”做单向推进
核心变量只有一个:cur,表示“从零点开始,我已经确认被覆盖到第几秒”。初始值是 0,因为它前面没有任何东西,所有位置都还空着。
然后遍历排序后的每个区间[l, r):
- 如果
l > cur,说明从cur到l这一段没有被任何区间覆盖,输出它,然后把cur推到r。 - 如果
l <= cur,说明这个区间和已经覆盖的部分接上了或者被包住了,不产生任何输出,只需要把cur推到max(cur, r)。
遍历完之后,如果cur还没到一天结束,说明尾巴上还有一段空档,单独输出。
这个逻辑的正确性可以用一个不变式来描述:每轮循环结束时,[0, cur)中的所有位置都已经被处理过,要么被覆盖、要么已经被作为空档输出。初始时cur = 0,[0, 0)是空集,成立。每一步要么扩展cur,要么在扩展之前把中间那段吐出去,不变式保持。循环结束后剩下的[cur, 86400)就是最后一段空档。
写代码的时候把这个不变式在脑子里过一遍,比事后对着错误输出瞎猜有用得多。
3.3 相交、包含、首尾相接的实际走向
光看公式容易晕,我拿几组具体数据走一遍,你就明白为什么max是关键了。
| 情况 | 已有覆盖 | 新区间 | 输出 | 新 cur | 说明 |
|---|---|---|---|---|---|
| 完全不相交 | [0, 100) | [200, 300) | [100, 200) | 300 | 中间空档被吐出 |
| 首尾相接 | [0, 100) | [100, 200) | 无 | 200 | l 等于 cur,不产生零长度段 |
| 部分重叠 | [0, 150) | [100, 200) | 无 | 200 | l 小于 cur,直接推进 |
| 完全被包含 | [0, 300) | [100, 200) | 无 | 300 | max 保住 300 不被缩回 200 |
| 完全包含旧的 | [0, 100) | [50, 400) | 无 | 400 | max 取到 400 |
这张表里最值得盯的是“完全被包含”那一行。如果代码里写成cur = r而不是cur = max(cur, r),遇到一个完全被包住的区间,cur就会从 300 缩回 200,后面本来应该被覆盖的[200, 300)就会被误判成空档输出,答案直接错。这个错误特别隐蔽,因为样例数据里往往没有“被包含”的情况,本地测不出来。
“首尾相接”那一行也是重点。因为用了半开区间,l == cur时l > cur不成立,什么都不会输出。如果你用的是闭区间表示,这里就得额外判断l > cur + 1才行,稍不注意就会输出一段xx:xx:xx - xx:xx:xx前后完全相同的垃圾数据。这就是我前面说“半开区间更省心”的实际理由。
4. 完整实现与两种解法对照:布尔打点法和排序扫描法
到这儿思路已经清楚了,可以上代码。我给两份:一份是排序扫描的正解,另一份是拿来做对拍基准的布尔打点法。很多人会觉得暴力法“不优雅”所以不屑于写,但在这道题上,暴力的那份反而是我调试时最依赖的工具,因为它足够笨、足够直白,出错的可能性极低,可以拿它当标准答案来验证正解。
顺便说一句,这道题的时间范围固定是 86400 秒,布尔数组的规模非常小,其实暴力法本身就能 AC,而且写起来更快、更不容易错。我在真正比赛的时候会怎么选?如果时间紧张、只求过题,我直接写布尔打点法;如果这道题后面还有变种(比如时间范围变成任意大、或者要处理多天),那就得上排序扫描。两种都得会,选哪个看场景。
4.1 一份可以直接提交的主流程代码
#include <bits/stdc++.h> using namespace std; int toSec(int h, int m, int s) { return h * 3600 + m * 60 + s; } string fmt(int sec) { char buf[16]; snprintf(buf, sizeof(buf), "%02d:%02d:%02d", sec / 3600, sec % 3600 / 60, sec % 60); return string(buf); } int main() { int n; if (scanf("%d", &n) != 1) return 0; vector<pair<int,int>> segs; segs.reserve(n); for (int i = 0; i < n; i++) { int h1, m1, s1, h2, m2, s2; scanf("%d:%d:%d - %d:%d:%d", &h1, &m1, &s1, &h2, &m2, &s2); int l = toSec(h1, m1, s1); int r = toSec(h2, m2, s2); if (l < r) segs.push_back({l, r}); // 防御:跳过零长度或倒序的脏数据 } sort(segs.begin(), segs.end()); const int DAY = 86400; int cur = 0; for (auto &p : segs) { int l = p.first, r = p.second; if (l > cur) { printf("%s - %s\n", fmt(cur).c_str(), fmt(l).c_str()); } cur = max(cur, r); } if (cur < DAY) { printf("%s - %s\n", fmt(cur).c_str(), fmt(DAY).c_str()); } return 0; }几个细节值得单独拎出来说。
segs.reserve(n)不是必须的,但养成习惯挺好,尤其是n可能到十万级别的时候,避免频繁扩容。这道题n一般不大,但写代码的时候不差这一行。
if (l < r)这个防御判断,是我被脏数据伤过之后加的。规范题面不会给零长度或者终点早于起点的区间,但万一呢?零长度区间其实无害(l == cur时不产生输出,cur = max也不会变),但倒序区间会让max逻辑变乱。加一行判断,成本几乎为零。
fmt(DAY)在DAY = 86400的时候会输出24:00:00。如果你的题面要求终点显示为23:59:59,那就在这里做特殊处理,或者干脆把DAY改成86399并且把最后一段的右端点用闭区间解释。这个点必须对着样例确认,别猜。
printf配%s和c_str()比cout快不少,PTA 上有些题的输入量不小,养成用printf的习惯没坏处。
4.2 布尔打点法:拿来做对拍基准最省事
#include <bits/stdc++.h> using namespace std; int toSec(int h, int m, int s) { return h * 3600 + m * 60 + s; } string fmt(int sec) { char buf[16]; snprintf(buf, sizeof(buf), "%02d:%02d:%02d", sec / 3600, sec % 3600 / 60, sec % 60); return string(buf); } int main() { int n; scanf("%d", &n); static bool busy[86400]; for (int i = 0; i < n; i++) { int h1, m1, s1, h2, m2, s2; scanf("%d:%d:%d - %d:%d:%d", &h1, &m1, &s1, &h2, &m2, &s2); int l = toSec(h1, m1, s1), r = toSec(h2, m2, s2); for (int t = l; t < r; t++) busy[t] = true; } int i = 0; while (i < 86400) { if (busy[i]) { i++; continue; } int j = i; while (j < 86400 && !busy[j]) j++; printf("%s - %s\n", fmt(i).c_str(), fmt(j).c_str()); i = j; } return 0; }这段代码的思路简单到不需要解释:把每一秒打上标记,然后扫一遍找连续的空白。它的正确性几乎是“显然”的,这正是它作为对拍基准的价值所在。当你正解和它输出不一致的时候,可以百分之百确定是正解错了,而不用怀疑基准本身。
当然它也有代价:如果题目把时间范围扩大到多天,或者时间精度到毫秒,数组就开不下了。所以它适合用来验证,不适合当作唯一方案。
4.3 两种解法的取舍与复杂度对照
| 维度 | 布尔打点法 | 排序扫描法 |
|---|---|---|
| 时间复杂度 | O(n + 86400) | O(n log n) |
| 空间复杂度 | O(86400) 固定 | O(n) |
| 代码行数 | 约 25 行 | 约 35 行 |
| 出错概率 | 低,几乎没有边界 | 中,max和边界易错 |
| 时间范围扩展性 | 差,范围一大就开不下 | 好,任意范围都能处理 |
| 适合场景 | 定点范围、赶时间、做对拍 | 通用解法、需要扩展的变种 |
从表里能看出来,这道题上两者的差距其实很小,86400 的常数在现在的机器上根本不叫事。真正拉开差距的是扩展性:如果这道题后面跟着一个“老板的作息表(加强版)”,时间范围变成从某年某月某日到某年某月某日,那布尔打点法直接废掉,排序扫描法一行不用改(只要把时间单位换一下)。所以我的建议是:学会排序扫描法作为主力,布尔打点法作为验证工具,两个都别丢。
5. 提交前我必定检查的几项与调试套路
代码写完、样例过了,其实只是走了半程。这道题的坑大部分在样例覆盖不到的地方,所以提交前的自查和提交后的调试才是真正决定能不能一次过的环节。我在被这类题反复教育之后,慢慢总结出一套固定的检查流程,现在基本能做到写完先自查、再提交,很少出现反复 WA 的情况。
这套流程的核心思路是:不要指望样例帮你发现边界问题,样例永远只覆盖最正常的情况。你得主动去构造那些“看起来很怪但完全合法”的输入,让程序在你自己手里先崩一次,而不是在判题机那里崩。
5.1 我遇到的几次典型错法定位过程
第一次 WA,是因为没排序。样例的输入恰好是有序的,所以我本地怎么测都对。定位方法很简单:把读入的区间在排序前后各打一遍,一眼就看出顺序是乱的。
第二次 WA,是cur = r写成了cur = max(cur, r)的反面。这次更阴险,因为我造的测试数据里区间都是递增的,没有包含关系。后来我专门加了一组[00:00:00 - 12:00:00]和[02:00:00 - 03:00:00],输出立刻多出一段03:00:00 - 12:00:00,问题暴露。
第三次 WA,是输出终点。我写的是86400,格式化出来是24:00:00,但题面期望23:59:59。这个只能靠仔细读样例,代码逻辑完全没错,纯粹是“理解题面”的问题。
第四次,n = 0的时候程序什么都不输出,正确答案是输出一整天的空档。原因是我把主循环写在了if (n > 0)里面,纯属手滑。
这四次的共同点是:本地全过,提交才炸。所以我现在的习惯是,样例跑完之后,立刻手工构造一组“极端输入”再跑一遍,具体造什么,下面这张表就是我的清单。
5.2 造数据和对拍的具体操作
我常用的测试数据构造思路,按优先级排:
- 空输入:
n = 0,期望输出一整天的空档(或者按题面要求的那一段)。 - 完全覆盖:一段
00:00:00 - 24:00:00(如果题面允许),期望什么都不输出。 - 包含关系:一个大区间套一个小相处,验证
max逻辑。 - 首尾相接:
08:00:00 - 09:00:00和09:00:00 - 10:00:00,验证不输出零长度段。 - 乱序输入:把正常数据打乱顺序喂进去,验证排序。
- 一天两头:
00:00:00 - 00:00:01和23:59:58 - 23:59:59,验证首尾边界不超过范围。
对拍的话,我用一份 Python 脚本随机生成合法输入,然后让两个程序分别跑,比对输出。生成逻辑很简单:随机切几个点,构造若干互不重叠的区间,确保不越界。跑个几百轮,基本能把隐藏的边界问题全揪出来。如果两个程序输出不一致,把那份输入留下来单独分析,比盯着代码看快十倍。
一个实际的小技巧:把输出重定向到文件再 diff,比在终端里滚屏肉眼比对靠谱得多。命令行里./a < in.txt > out1.txt,python b.py < in.txt > out2.txt,然后diff out1.txt out2.txt,差异一目了然。
5.3 提交前的自检表
| 检查项 | 具体做法 | 踩坑后果 |
|---|---|---|
| 是否排序 | 看代码里有没有sort | 乱序输入直接错 |
是否用max推进 | 看cur的更新语句 | 被包含区间导致多输出 |
| 零长度段是否过滤 | l > cur而不是l >= cur | 输出xx - xx的无效行 |
| 格式化是否补零 | 三个字段都用%02d | 输出8:0:0格式不符 |
| 终点值是否正确 | 对比样例里的最大时间 | 24:00:00与23:59:59之争 |
n = 0是否处理 | 手造一组空输入跑一遍 | 该输出时不输出 |
| 是否有未初始化变量 | 编译加-Wall看警告 | 随机性错误,极难复现 |
| 输入是否吞掉空格 | 用scanf格式串而非定长切分 | 分隔符多空格时解析失败 |
这张表我现在基本是条件反射地过一遍,前后不超过两分钟,但省下来的重交次数远不止两分钟。
最后分享一个小经验:这道题做完之后,别急着关掉。把代码里的DAY改成一个参数,试试“如果一天不是 24 小时而是任意长度”会怎样,你会发现排序扫描法几乎不用改,而布尔打点法立刻需要重新设计。这种“改一个条件看方案还成不成立”的练习,比单纯刷十道同类题更能建立起对区间问题的直觉。我自己就是靠这种折腾,慢慢把一类题的解法从“背下来”变成了“想得起来”。