如果你在某门算法实践课上熬到第 14 周,大概率会遇到这么一道“限时大模拟”的题:猫睡觉问题。名字听着像休闲游戏,实际上是一道非常典型的按时间轴推演状态的模拟题。它的核心不是让你发明什么高深算法,而是考察你能否在“跨天的时间区间、连续时长限制、作息安排”这些细节堆砌中,不翻车地推完整个流程。
当时我拿到这道题的第一反应是“这不就是个日程表问题嘛”,真写起来才发现,里面藏着的坑一个接一个,尤其是跨天、端点语义、连续状态追踪这些点,稍不注意就会在某个刁钻的测试点上挂掉。今天就来把这个题从头到尾拆一遍,把我实际踩过的坑、试过的方法、最后沉淀下来的模板都写出来,给同样被这道题折磨过、或者马上要面对限时模拟题的读者一点参考。
1. 猫睡觉问题到底在考什么
1.1 我印象中的题目版本
先把我熟悉的版本放在这里,方便后面讨论。题目会给出若干只猫的“活动时间区间”,比如某只猫每天从 08:30 到 12:00、再从 18:00 到 22:30 必须清醒;同时给两个硬性条件:每只猫每天至少睡满 A 小时,且连续清醒时间不能超过 B 小时。你要判断能不能找到一种安排,让每只猫在满足所有硬性条件的前提下,把每天的作息都排出来,并且在题目要求输出格式的时候,把睡觉区间列出来。
不同平台的版本会有一点差别,比如有的会把“活动区间”改成“必须待在猫窝里的时段”,有的只给一只猫,有的会给多只猫。但骨架就是上面这几件事:时间区间、连续清醒上限、总睡眠下限、跨天循环。题目称它为“限时大模拟”,我觉得这个定位非常准:没有复杂的算法标签,考的就是一点一点把约束落到代码里的耐心和准确度。
1.2 它难在哪里
这道题真正的难点不在“思路想不到”,而在“写出来全是细节”。
第一个坑是时间表达。输入给的是“08:30-12:00”这种字符串,你要么一路用字符串比较,要么统一换算成分钟。字符串比较在跨天之后会非常恶心,因为“00:30”在字典序上小于“23:00”,但它代表的时间其实在第二天。所以几乎所有靠谱做法都是先把时间换成以分钟为单位的数字,一天就是 0 到 1439。
第二个坑是跨天。猫的作息是每天循环的,昨晚 23:00 到今早 01:00 的区间,本质上是 (1380, 60+1440),而不是一个简单的“结束小于开始”的区间。处理不好,连续清醒时间的累加就会错位。
第三个坑是边界语义。题目里“12:00 到 14:00 必须清醒”,那 12:00 这一瞬间到底算清醒还是可以切换?14:00 这一瞬间呢?这类模拟题最怕的歧义就在端点。我习惯用半开半闭区间 [start, end) 理解所有时间区间:活动区间开始时刻必须清醒,结束时刻已经可以切换状态。这样写代码最不容易出现“多算一分钟/少算一分钟”的偏差。
第四个坑是循环状态。因为作息是每天重复的,你从一天中的任意一个时刻开始模拟,都要能正确接上“昨天延续下来的连续清醒时间”。只看 0 点到 24 点往往会漏掉“这只猫已经连续清醒了 20 小时,再清醒 1 小时就要超限”的场景。
这四个坑如果没提前想清楚,写出来的代码大概率会在某个测试点悄悄翻车,而且很不好调。
2. 建模:把生活问题变成状态机
2.1 时间统一与区间拆分
我第一步永远是写两个基础函数:一个是把“HH:MM”转成分钟数,一个是把“HH:MM-HH:MM”这个字符串转成一对整数 (start, end)。转成分钟之后,所有的比较、累加都变成纯整数运算,少掉一大半字符串比较的烦恼。
def to_minute(s): h, m = map(int, s.split(':')) return h * 60 + m def parse_interval(text): left, right = text.split('-') start = to_minute(left) end = to_minute(right) return start, end然后处理跨天区间。如果 end 小于等于 start,说明这个区间跨过午夜,例如“23:00-01:00”,解析出来是 (1380, 60)。如果直接拿 1380 和 60 比较,逻辑上会乱。我的处理办法是:把这种情况拆成两个区间,或者把 end 加上 1440 变成“第二天”的时间坐标。
def normalize_interval(s): start, end = parse_interval(s) if end <= start: end += 1440 return start, end这样处理后,区间就一定是“左端点小于右端点”的干净结构,后续模拟区间合并、时长计算都能直接用减法。如果题目输入本身可能包含多段活动区间,就把所有区间都这样规范化,然后按左端点排序、合并重叠区间,得到一整天的“必须清醒区间集合”。
2.2 用状态机理解猫的一天
把每只猫看成一个小型状态机:它只有两个状态,清醒和睡觉。清醒状态有一个属性叫“连续清醒时间”,睡觉状态有一个属性叫“累计睡眠时间”。
时间从某个点开始往前推进,每到一个决策点,都会根据当前时刻是否落在“必须清醒区间”内,决定猫能不能切换到睡觉状态。能睡的时候就睡,不能睡的时候就保持清醒。如果发现连续清醒时间即将超过上限 B,就必须在下一个可选睡觉的时刻立刻睡觉。
这里有一个核心的贪心策略:在可选区间里,尽量早睡。
为什么尽量早睡?因为题目只要求“至少睡 A 小时”和“连续清醒不超过 B”,并没有要求猫必须在某个固定时间醒。既然没有额外约束,早睡就是最安全的策略——它能在不违反清醒上限的同时,尽早积累睡眠时长。反过来,如果拖到快要超时才睡,很容易出现“想睡的时候已经到了必须清醒区间”的窘境。
用一个生活类比:就像你通宵赶工,手上的任务必须在某个时间点做完,在任务间隙能睡就睡,否则到了后半夜连续清醒时间爆表,整个人状态就崩了。猫的作息也是这样。
2.3 把“可行动区间”和“必清醒区间”分开
拿到一天的必清醒区间后,把它们看作在时间轴上挖出的“墙”。剩下的部分就是猫可以自由选择睡觉的时间。注意,可自由选择不等于必须睡觉,只是说在这个范围内睡觉是允许的。
在实现上,我喜欢先把必清醒区间按时间排序、合并,然后扫描一遍时间轴,把“墙”和“墙”之间的空隙提取出来,得到一个个“可睡区间”。这些可睡区间就是后面贪心安排的候选池。
如果有些版本的题目还要求“猫必须在猫窝里才能睡觉”,那就在可睡区间集合上再叠加一个“猫窝可用区间”的取交集操作。这个扩展其实很自然:先筛出“不活跃且位置允许”的时间区间,再继续跑同样的贪心流程。这也是为什么我建议把区间处理做成独立模块,后面加条件时只需要改候选池的构造逻辑。
3. 核心实现:贪心推演与循环校验
3.1 从“按分钟扫”到“按区间推进”
写模拟题最容易犯的操作错误是“按分钟一格一格扫”。一分钟扫一次,一天 1440 格还能忍,两天 2880 格也还行,但代码里全是 if、else 判断当前分钟是不是该切换状态,逻辑会非常碎且难调。
我更推荐“按事件区间推进”:先确定当前决策点(比如可睡区间的起点),直接跳到下一个决策点,而不是每分钟都检查。这样代码量少,且每段逻辑都可以独立测试。
举个具体的流程:
- 把所有必清醒区间归一化、排序、合并,得到墙区间数组 walls。
- 根据 walls 生成可睡区间数组 sleep_slots。
- 从某个固定起点(比如所有区间中的最早时间,或直接取第 0 分钟)开始模拟一只猫的两天:
- 维护当前时刻 cur、连续清醒时间 awake、当日累计睡眠 total_sleep。
- 如果 cur 落在可睡区间内,且连续清醒时间已经接近上限 B,就安排一段睡眠,更新总睡眠时长,重置连续清醒时间为 0。
- 如果 cur 落在必清醒区间内,则持续清醒,累计清醒时间,并检查是否超过 B。
- 因为作息是循环的,至少要模拟完整的两天(2880 分钟),然后验证第二天的 24 小时是否满足约束。
为什么模拟两天而不是一天?因为睡眠和清醒状态从“前一天”延续下来,一天内很难覆盖所有可能的起始状态。跑两天之后,第二天相当于“预热”,让状态机进入稳态再验收,能覆盖从前一天延续下来的连续清醒时间。
3.2 一份可运行的简化实现
为了不空谈,我写一个简化版本的 Python 代码,它处理单只猫的场景:输入一天的必清醒区间、A 和 B,输出一种尽量早睡的安排(以区间列表形式)。
DAY = 24 * 60 def to_minute(s): h, m = map(int, s.split(':')) return h * 60 + m def format_minute(t): t %= DAY return f"{t // 60:02d}:{t % 60:02d}" def build_sleep_slots(walls): # walls: list of [start, end),已归一化、已排序 merged = [] for s, e in walls: if not merged or s >= merged[-1][1]: merged.append([s, e]) else: merged[-1][1] = max(merged[-1][1], e) slots = [] cur = 0 for s, e in merged: if s > cur: slots.append((cur, s)) cur = max(cur, e) if cur < DAY: slots.append((cur, DAY)) return slots def arrange_sleep(slots, A, B): plan = [] total = 0 need = A * 60 for s, e in slots: if total >= need: break # 能睡就睡满整个可睡区间,贪心早睡 plan.append((s, e)) total += e - s return plan注意,这份代码是我为了演示“区间推进”思路写出来的简化版本,它假设“只要有可睡区间就先睡满”。真实答卷时,你还需要把“可睡区间”和“必清醒区间”交错起来做更精细的判断,尤其要处理“可睡区间结束、紧接着大段必清醒区间,导致清醒超限”的情况。千万别把这二十行代码当成标准答案直接交上去,要把它理解成“先把结构搭起来,再按题目约束补细节”的模板。
3.3 校验函数怎么写
在所有安排做完之后,一定要写一个独立的校验函数,用最朴素的方式重新检查一遍:把一天的时间轴展开,逐分钟判断当前猫的状态是清醒还是睡觉,然后计算最长连续清醒时间和总睡眠时长,最后和 A、B 比较。
校验函数要和安排逻辑分开写。安排逻辑是“我怎么安排”,校验逻辑是“安排结果是否合法”。两套独立代码对拍,能发现大量细微错误。比如安排逻辑里的端点是否差了一分钟,用逐分钟扫描的校验函数一眼就能看出来。
逐分钟扫描虽然慢,但一天只有 1440 分钟,就算跑 100 个测试点也就是 14 万次循环,对 OJ 来说完全不算事。所以校验逻辑尽管写得朴素,不要贪图优化。
def validate(plan_intervals, walls, A, B): # 建两天的 sleep_flags,容纳跨天的睡觉区间 sleep_flags = [False] * (DAY * 2) for s, e in plan_intervals: for t in range(s, min(e, DAY * 2)): sleep_flags[t] = True # 校验第二天的 24 小时,能正确继承第一天的状态 total_sleep = 0 max_awake = 0 cur_awake = 0 for t in range(DAY, DAY * 2): if sleep_flags[t]: total_sleep += 1 cur_awake = 0 else: cur_awake += 1 max_awake = max(max_awake, cur_awake) return total_sleep >= A * 60 and max_awake <= B * 60这个校验函数里,我校验的是“第二天”的 24 小时,因为第二天相当于包含了前一天的延续状态,状态更真实。如果 plan 里面有跨天区间,比如 (1380, 1500),它在 sleep_flags 里会被正确标记到第二天的前 60 分钟,校验时能正确识别为睡眠状态。
4. 那些年我们一起踩过的坑
4.1 端点差一分钟:最隐蔽的翻车点
我印象里,这类模拟题出错率最高的问题就是端点。同样是“12:00-14:00”,有的同学写成左闭右闭,有的写成左闭右开。平时练题可能没事,但只要测试点里出现“刚好 12:00 切换状态”“刚好 14:00 可以开始睡”这种边界数据,就会挂。
我自己的习惯是统一用左闭右开 [start, end),并且在代码开头写注释:
# 所有区间采用 [start, end) 语义:start 时刻必须清醒,end 时刻可切换这个注释的价值不只是提醒自己,更是给读代码的人明确规则。后续无论是算时长(end - start 就是分钟数)、判断“当前时刻是否在区间内”还是合并区间,都不需要额外加一减一。
注意:如果题目描述里明确写了端点归谁,或者输出格式要求包含端点时刻,那就以题目为准。没有明确说明时,右开区间通常是模拟题里最不容易出歧义的选择。
4.2 只跑一天是不够的
有位同学曾经很困惑地找我调代码:他的安排逻辑看起来完全正确,必清醒区间、可睡区间、睡眠时长、连续清醒检查都在做,但某个测试点就是过不了。后来我发现,他是从 00:00 开始模拟,并且默认 00:00 时刻猫刚睡醒、连续清醒为 0。可实际上,前一天的 22:00 到 00:00 猫可能一直是清醒的,00:00 时点的连续清醒已经积累了 2 小时。如果 B 比较小,这 2 小时就是压垮骆驼的最后一根稻草。
解决办法就是前面说的:模拟区间至少拉长到两天,校验时取“第二天”的 24 小时。或者,在单日模拟开始前,先根据前一天的区间预计算好当天的初始连续清醒时间。我更推荐前者,因为它不用额外处理“初始连续清醒”的推导,让代码更简单。
4.3 多只猫之间的互相影响
如果题目是“多只猫”,而不是单只猫,情况又会多一层:猫之间可能存在资源竞争。比如某些版本会规定“同一时刻猫窝只能容纳一只猫”,或者“某只猫睡觉时不能有其他猫打扰”。这种情况下,单纯对每只猫独立做贪心是不够的,需要把所有猫的时间安排汇总到一张总时间表上,做冲突检测,冲突时按某种优先级调整。
不过,很多课程作业里的“猫睡觉问题”是多只猫但互不干扰,每只猫独立安排即可,最后统一输出。关键是读题时先确认“猫之间有没有共享资源的约束”,有和没有的解法复杂度完全不一样。
我建议拿到题先做一件事:把所有约束条件列成一张表,分成“每只猫自己满足的约束”和“全局约束”两类。自己满足的约束就独立处理;全局约束再考虑排序、抢占或回溯。这样无论题目怎么变形,你都有清晰的拆解路径。
4.4 输出格式与“无解”的判定
有些版本要求输出“不行”的情况,有些要求输出具体睡觉区间,有些要求按时间排序输出。这里有三个容易出问题的点:
第一,输出排序。睡觉区间可能跨天,比如“23:30-01:00”,如果按字符串排序,00:00 会被排到 23:00 前面。稳妥做法是先把所有区间转成分钟数,排序后再格式化输出。
第二,无解判定。校验函数发现总睡眠不足或连续清醒超限,就输出无解。但要注意:因为作息循环,可能出现“按第一天看合法、按第二天看不合法”的情况,所以我习惯把“第二天的校验”作为唯一权威。
第三,区间的合并。如果把连续的几段睡眠输出成一段还是多段,题目可能有要求。没有要求时,输出合并后的结果更简洁,但一定要保证合并后的区间仍是合法的,别把两个段之间夹着的清醒时间也吞进去。
4.5 调试利器:时间戳打点
模拟题的调试比算法题更依赖“过程信息”。我不止一次在遇到奇怪错误时,靠着打印每个决策点的时刻和状态才找准问题。
我常用的调试手段是设置一个 debug 开关,输出类似这样的信息:
当前时刻 540,进入可睡区间 (540, 720),连续清醒 130 分钟,安排睡眠 当前时刻 720,进入必清醒区间,连续清醒清零这种输出能直观看出你的状态机在哪个时间点“态度突变”,通常也是最容易埋 bug 的地方。全部调通后,把 debug 关掉再交,不影响性能。
5. 一套可复用的模拟题调试流程
5.1 构造边界测试用例清单
没有什么比“手写几个刁钻测试点”更能快速暴露代码问题。我已经习惯把下面这些场景存成模板,每次都优先跑:
- 活动区间占满整个白天,比如 00:00-23:59,几乎不给睡眠机会,测试无解分支。
- 活动区间只有 00:00-00:00,相当于没有活动区间,猫全天可睡,测试简单场景。
- B 特别小,比如连续清醒不能超过 30 分钟,测试频繁切换睡眠的逻辑。
- A 特别大,比如每天要睡 23 小时,测试“猫快醒不来了”的极端。
- 活动区间跨午夜,比如 22:00-02:00,测试跨天处理。
- 活动区间紧邻,比如 08:00-09:00 和 09:00-10:00,测试边界合并逻辑。
- 输入时间刚好是 00:00、23:59、12:00 等特殊时刻,测试端点语义。
每个测试点跑完,不光看输出对不对,还要顺手用校验函数过一遍。我的习惯是每个测试点连跑三次:一次用安排逻辑,一次用独立校验函数,一次用逐分钟可视化打印(如果输出量小的话)。
5.2 从这道题延伸出去的通用能力
“猫睡觉问题”虽然只是一道周测模拟题,但它背后其实是一组非常通用的能力。
第一是时间序列建模。把生活语言描述的规则映射成分钟级的整数数组或区间数组,这件事在做游戏日程、排班系统、监控告警时间轴分析时都会用到。
第二是状态机与贪心选择。什么时候必须切换状态,什么时候可以拖延,哪类问题适合尽早选择而不是晚选,这类判断力不是靠背模板练出来的,而是靠真写几道模拟题养成的。
第三是“对拍思维”。安排逻辑和校验逻辑分离开,互相验证,这套方法论在工程代码里同样受用。哪怕是写业务逻辑,我也经常把“生成数据的函数”和“校验数据的函数”分文件写,跑一轮随机对拍,能省下大量联调时间。
所以我的建议是:不要只把这道题当成应付第 14 周限时模拟的作业,多花半小时把上面几个抽象能力提炼到自己脑子里,后面写工程代码你会感谢这道题的。
5.3 说点我自己的习惯
我现在每次写模拟题,第一件事不是写代码,而是先在草稿纸上画一条从 0 到 1440 的时间轴,把所有必清醒区间标上去,再标出可睡区间。把区间端点规则(左闭右开还是左闭右闭)写旁边,然后才开始动手。这个习惯就是从“猫睡觉问题”养成的。
有一次我偷懒,画完时间轴就直接写代码,忘了标注端点规则,结果代码逻辑里一会儿用左闭右开,一会儿半闭半开,调了一个小时才发现 14:00 这个时刻被两段逻辑重复处理了。后来我把端点规则写成注释贴在函数顶部,再没出过这类问题。
如果再让我给出一条建议,那就是:模拟题的代码不怕长,怕的是逻辑不清晰;把每一步都拆成独立的小函数,哪怕多写几十行,也比堆在一坨 while 循环里强得多。你把它当作“给未来的自己留线索”,写起来会顺畅很多。