ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

L1-043阅览室:模拟题的经典解法与状态机思维

L1-043阅览室:模拟题的经典解法与状态机思维 1. 题目到底在说什么先读懂阅览室的规则第一次看到“L1-043 阅览室”这个题很多人以为是道阅读理解结果发现是一道典型的模拟题。PAT拼题A基础级题目里这种带有“时间计算”和“状态标记”的模拟题很常见而L1-043就是其中非常有代表性的一道。先说说这道题的实际场景。假设阅览室里有若干本书每本书都有一个编号。读者借书时登记一条记录格式是“书号 操作 时间”操作只有两种S代表借出借书成功E代表归还。你拿到一整天的操作记录需要统计出当天所有“有效借阅”的次数以及平均阅读时间。输出有两部分有效借阅次数和平均阅读时间精确到分钟四舍五入取整。这里最核心的一个词是“有效借阅”。什么叫有效不是所有的S和E都能配对。题目隐含了几个规则如果一本书连续被借出多次连续多个S只有最后一次S是有效的因为前面的借出被后面的借出覆盖了书还没来得及归还又被借走了现实中前面的借阅记录应该作废。如果一本书归还时之前没有借出记录那这条E是无效的直接忽略。一个完整的有效借阅必须是一对S和ES在前E在后中间不能跨越“当天结束”。举个例子假设书1的记录是这样的8:00 S9:00 S10:00 E。那么有效借阅只有一次起始时间是9:00结束时间是10:00阅读时长60分钟。8:00那次S被9:00的S覆盖掉了。再举个例子书2只有一条记录10:30 E。这条E没有对应的S是无效的不计入借阅次数阅读时间也不加。这道题适合谁来练如果你是准备考PAT乙级的这道题是典型的基础模拟题考察的是你对状态记录和数据更新的掌握程度。如果你是在刷OJ的新手这道题也非常适合训练读题能力——因为真正卡住大家的往往不是代码写不出来而是规则没读透。我自己当年第一次做这道题就是因为没搞懂“连续S取最后一次”这个规则硬生生错了好几发。这道题整体难度不大但细节非常多属于那种“看着简单做起来到处是坑”的题目。接下来我把整个解题思路、代码实现和踩坑经验拆开揉碎了讲清楚。2. 解题思路拆解模拟借阅流程的核心逻辑2.1 用什么数据结构来记录借出状态这道题的数据规模不大书号范围通常不超过1000有些版本题目里写的是书号范围1到1000所以最简单的办法就是开一个数组下标就是书号数组里存的是“借出时间”。这个数组里存的时间怎么表示题目给的时间格式是“HH:MM”也就是小时和分钟。我们有两种处理方式一种是把“HH:MM”拆成两个整数分开存储比较的时候先比小时再比分钟另一种更简洁直接换算成“从0点开始的分钟数”也就是“小时 × 60 分钟”。这样一来求阅读时长就变成了简单的减法还书时间减去借书时间。我强烈建议用第二种方式也就是统一转成分钟数。原因很简单一是比较大小方便数值直接比二是计算差值方便不需要处理小时和分钟之间的借位问题。比如8:30借出9:15归还如果分开算9:15减8:30需要借位15减30不够要从小时的9借1变成8:75减8:30等于45分钟。虽然这个计算人脑很容易处理但写代码时多一个分支就多一个出错的可能。转成分钟后就一句话555 - 510 45。所以核心数据结构就是int last_borrow_time[1005]; // 下标为书号记录该书最近一次有效借出的分钟时间初始值怎么设置没有借出记录时应该设置成一个特殊值比如-1用来判断“当前没有正在借出的记录”。每次还书时先检查这个值是不是-1如果是-1说明没有借出记录这条E无效直接忽略。2.2 单天处理的完整逻辑每天的记录处理流程是这样的第一步初始化所有书的借出时间为-1也就是清空状态。第二步循环读取每一条操作记录。每条记录有三个部分书号、操作字符、时间。格式是“书号 操作 时间”中间用空格分隔。读取时用scanf(%d %c %d:%d, book_id, op, hour, minute)这样的方式其中%c前面的空格用来跳过空白字符。这个格式化输入是这道题的一个小坑如果格式写不对读到的op字符可能是换行符或者空格导致判断错乱。第三步判断操作类型如果是S就把last_borrow_time[book_id]更新为当前时间。这里有一个容易被忽略的点——不管这本书之前有没有借出记录S操作都直接覆盖。因为“连续S取最后一次”前面即使有记录也作废了。如果是E先判断last_borrow_time[book_id]是否等于-1。如果是-1说明没有借出忽略这条E。如果不是-1说明这是一次有效归还借阅次数加1阅读时长累加“当前时间 - 上次借出时间”然后把last_borrow_time[book_id]重置为-1防止同一本书被重复计算。第四步当某一天的记录读到“0 0 0”时意味着这一天结束。注意这个“0 0 0”不是一条真实的借还记录只是一个结束标志读到它就该输出当天的统计结果然后进入下一天的处理。这里有一个非常关键的细节输出当天的结果后一定要把借阅次数和总时长清零同时把所有书的借出状态重置为-1。否则下一天的统计会带着前一天的数据结果全错。第五步所有天处理完后结束。2.3 平均时间的四舍五入计算题目要求平均阅读时间输出为整数且四舍五入。很多人这里会翻车。平均时间的计算方法是总阅读时长 / 有效借阅次数。但C语言的整数除法是向下取整的比如45分钟除以2次数学上是22.5四舍五入后应该是23但整数除法直接得到22。正确的做法有两种第一种用浮点数计算后再四舍五入。比如double avg (double)total_minutes / count; int result (int)(avg 0.5);第二种继续用整数运算但加一个技巧性处理int result (total_minutes * 1.0 / count 0.5); // 或写成 int result (total_minutes count / 2) / count; // 这个写法需要小心边界更稳妥的方式是直接用浮点数加0.5再取整。但要特别注意一个陷阱如果有效借阅次数为0除法会出现除零错误此时平均时间应该输出0而不是报错或输出垃圾值。这一点很多初学者会忽略。我在实际做题时习惯先判断count是否为0如果为0直接输出“0 0”否则再计算平均数。这样既避免了除零又能保证输出格式正确。2.4 为什么数组状态清理很重要这道题输入是多天的数据每一天都是独立的统计周期。很多人第一次写的时候会忘记在每天结束时清理状态数组导致第二天的数据继承了第一天的借出记录。举个例子第一天书3在10:00被借出但一直到当天结束都没有归还。如果不清状态第二天的记录读到书3的E时会发现last_borrow_time[3]不是-1就自动配对了但实际上这两个记录不是同一天的不能配对。这个错误非常隐蔽因为单看代码逻辑看不出问题只有测试数据里出现“跨天未归还”的情况时答案才会错。我印象中这道题的测试点里就有这类跨天场景所以清理状态这一布必须重视。3. 完整代码实现与逐段解读下面给出一个完整的C语言实现。我特意把注释写得详细一些方便对照理解。#include stdio.h int main() { int n; // 天数 int book_id; // 书号 char op; // 操作符S或E int hour, minute; // 小时和分钟 int time_minutes; // 换算后的总分钟数 int last_borrow_time[1005]; // 记录每本书最近一次借出时间-1表示未借出 int count; // 当天的有效借阅次数 int total_minutes; // 当天的总阅读时长分钟 int i; // 初始化所有书为“未借出”状态 for (i 0; i 1005; i) { last_borrow_time[i] -1; } scanf(%d, n); while (n--) { count 0; total_minutes 0; // 每次新的一天开始必须将所有书的状态重置为“未借出” for (i 0; i 1005; i) { last_borrow_time[i] -1; } while (1) { scanf(%d %c %d:%d, book_id, op, hour, minute); // 结束标志书号为0操作符为0时间为0 if (book_id 0 op 0) { break; } time_minutes hour * 60 minute; if (op S) { // 借出直接覆盖该书的借出时间 // 连续两次S时以最后一次为准 last_borrow_time[book_id] time_minutes; } else if (op E) { // 归还如果之前有借出记录才是一次有效借阅 if (last_borrow_time[book_id] ! -1) { count; total_minutes time_minutes - last_borrow_time[book_id]; last_borrow_time[book_id] -1; // 归还后重置状态 } // 如果没有借出记录这条E直接忽略 } } // 输出当天结果 if (count 0) { printf(0 0\n); } else { // 四舍五入浮点数 0.5 取整 int avg_minutes (int)((double)total_minutes / count 0.5); printf(%d %d\n, count, avg_minutes); } } return 0; }这段代码的核心逻辑非常集中就是在while(1)循环里根据操作符做两件事S就更新借出时间E就判断是否配对成功。下面我逐段拆解一些容易被忽略的细节。第一scanf的格式字符串是%d %c %d:%d。%d和%c之间有一个空格这个空格的作用是跳过空白字符确保%c读到的是真正的操作字符而不是换行符或空格。如果你写成%d%c%d:%d当输入中换行符紧跟在书号后面时%c会读到换行符导致op既不是S也不是E整个逻辑就乱了。第二结束标志的判断条件是book_id 0 op 0。这里有个细节需要注意输入的结束行是“0 0 0”它的第二项是字符0所以用op 0来判断。有些同学可能会写成op 0这在ASCII里就是空字符永远不成立会陷入死循环。第三为什么S操作不需要判断之前是否有借出因为规则是“连续S取最后一次”。即使之前有借出记录后面的S也会把前面的覆盖掉所以直接赋值就行不需要额外判断。第四E操作配对的本质是状态检查。如果last_borrow_time[book_id] ! -1说明这本书处于借出状态这次归还有效。配对成功后要把状态重置为-1防止后续再来一条E把同一本书又配对一次。这个重置操作很关键很多人会忘记导致同一本书被统计两次。第五每天结束时的状态清理。我在代码里写了两处初始化一处是程序开始时的全局初始化另一处是每一天开始前的重置。其实全局初始化可以省略因为每一天开始前都会重置。但保留全局初始化是一个好习惯万一哪天把重置逻辑删了代码也不会立即出错。当然正规的写法是每开一天新循环就重置一次这个千万别漏。4. 踩坑实录那些年我们错过的边界条件4.1 格式化输入导致的字符读取错乱这是这道题最经典的坑。很多第一次做这题的人scanf写成了%d%d:%d或者%d %c%d:%d导致读到的op是换行符。比如输入1 1 S 08:10如果格式字符串是%d %c %d:%d那么第一个%d读书号1空格跳过%c读到S空格跳过%d:%d读8和10一切正常。但如果是%d%c %d:%d第一个%d读到1后%c会直接读取1后面的空格op变成空格字符判断op S就永远不成立。这个问题的排查方法很直接在循环里加一行调试输出打印出读到的book_id、op、hour、minute看op到底是什么值。我自己做题时经常会在调试时printf(book%d op%c time%d:%d\n, book_id, op, hour, minute)一旦看到op是空白或者数字就知道是格式字符串的问题了。4.2 平均时间为0的判断顺序有些同学会这么写printf(%d %d\n, count, (int)((double)total_minutes / count 0.5));当count为0时这一步会发生除零异常。在C语言里整数除以0会导致运行时错误浮点数除以0可能得到inf但结果都不是题目要的“0 0”。正确的顺序是先判断count是否为0再计算平均时间。这个顺序不能颠倒因为除零的判断必须在除法之前。4.3 一天结束后忘记重置累计变量这个坑特别隐蔽。如果每天循环开始前没有把count和total_minutes清零那么第二天的输出会累加第一天的数据。比如第一天有3次借阅第二天有2次借阅如果不重置第二天的输出会变成5。我在实际做题时遇到过一个更隐蔽的情况count清零了但total_minutes忘了清零。结果就是借阅次数正确平均时间巨大——因为总时长包含了前一天的数据。这种错误很难通过样例数据发现因为样例通常只有一天的数据或者每一天的数据恰好都正常归还。排查方法很简单检查while(n--)循环体内部count和total_minutes的赋值语句必须放在循环体最前面不能放在循环外。4.4 连续借出时只保存最后一次这个规则是这道题的核心考点。很多人第一次做的时候看到S就以为有借出记录了直接设置状态为“已借出”。如果连续出现两次S第二次S来了之后前面的借出记录应该被覆盖但有些人用了标记法而不是时间记录法就无法覆盖。举一个具体的例子输入1 S 08:10 1 S 08:20 1 E 08:30正确的输出应该是一次借阅时长10分钟08:20到08:30。但如果你只用一个bool数组标记“是否借出”第一次S把标记设为true第二次S来了你什么都不做E的时候时间s是08:10算出来的时长是20分钟就错了。因此正确做法是记录时间值而不是只记bool状态。S操作直接覆盖时间值E操作读取当前时间值与上次时间值做差。这就是为什么我选择用int数组存分钟数而不是用bool数组。4.5 结束标志的字符判断这个坑和格式化输入有关。结束标志是“0 0 0”其中第二个0在scanf里是用%c读取的所以op会得到字符0。判断时应该用op 0。有些同学会写成op 0这在C语言里是判断ASCII码为0的空字符和字符0ASCII码48完全不同。一旦写成op 0结束标志永远不会被识别程序就会尝试继续读取可能读到EOF或者虚拟内存未知数据导致运行超时或者结果错误。还有一个细节是当读到结束标志时不需要把结束标志当作一条记录处理。break直接跳出循环即可但break之前不需要做其他事情。注意输出统计结果必须在break之后也就是while(1)循环外面。4.6 同一天内同一本书多次有效借阅还有一种情况同一本书在同一天内借出、归还、再借出、再归还。比如1 S 08:00 1 E 09:00 1 S 10:00 1 E 11:00这算两次有效借阅每次时长60分钟总计120分钟。正确处理的关键是第一次E配对成功后把状态重置为-1这样第二次S来了状态又是-1赋值为10:00第二次E来正常配对。如果第一次E之后没有重置第二次E就会用10:00减上次的S时间但上次的S已经被E覆盖了逻辑会乱。所以每次E配对成功后重置状态这一步不可或缺。5. 从题目到实战这类模拟题的通法总结5.1 状态机思维在编程题中的应用L1-043其实是个非常典型的状态机问题。每一本书有两种状态已借出和未借出。S操作是一个状态迁移从“未借出”或“已借出”迁移到“已借出”E操作也是一个状态迁移从“已借出”迁移到“未借出”同时产生一次有效借阅。这种“状态 事件”的模型在真实业务系统里特别常见。比如会议室预约系统一个会议室有“空闲”和“占用”两种状态预约事件把空闲变成占用释放事件把占用变成空闲。再比如图书馆的图书管理系统本身就是这种状态机的真实写照。所以做这类模拟题建议先用状态机的方式梳理一遍逻辑有哪些实体书每个实体有哪些状态借出/未借出有哪些事件借书S/还书E每个事件会导致什么状态迁移状态迁移时需要执行什么操作更新计数、累加时长按照这个顺序理清楚再动笔写代码思路会清晰很多。5.2 边界条件驱动的测试思维这道题为什么难倒那么多人不是算法复杂而是边界条件太多。我建议大家养成一个习惯写完代码后先别急着提交手动构造几组测试数据覆盖以下几种场景只有S没有E的情况所有借出都未归还只有E没有S的情况所有归还都是孤儿记录连续多次S然后一次E的情况同一本书借了还、还了借的重复场景跨天未归还的场景有效借阅次数为0的场景平均时间需要四舍五入进位的场景我这里给出一组自测数据你可以拿这段代码直接跑3 1 S 08:10 2 S 08:20 1 E 09:00 2 E 09:10 0 0 0 1 S 08:10 1 E 08:20 1 E 08:30 0 0 0 1 S 08:10 2 E 08:20 0 0 0第一天的期望输出是有效借阅2次总时长5050100分钟平均50。第二天的期望输出是有效借阅1次时长10分钟平均10注意第二次E因为状态已经重置配对失败被忽略。第三天的期望输出是有效借阅0次时长0平均0因为S没有对应EE没有对应S。拿这组数据跑一遍如果输出和预期一致说明核心逻辑基本没问题。5.3 从OJ题目到工程实践的迁移有人可能会问这种模拟题除了考试还有没有实际意义其实非常有意义。就拿“状态记录 事件处理 时间计算”这个组合来说很多真实系统都是这么做的。比如停车场管理系统每个车位有“空闲/占用”状态车辆入场是一次状态更新出场是一次状态更新同时计算停车时长和费用。再比如工单管理系统每个工单有“待处理/处理中/已关闭”状态每次状态流转都会记录时间戳最终统计处理时长。这些场景和L1-043的核心逻辑几乎一样用一个数据结构记录状态用事件驱动状态变化在状态变化时做统计和计算。所以把这道题吃透等于掌握了一类系统的核心实现思路。5.4 扩展思考如果数据量变大怎么办这道题的书号范围不超过1000用数组完全够用。但如果书号范围变成一个很大的数字比如10^9再用数组就不现实了。这时候可以换成哈希表用unordered_mapC或字典Python来记录状态逻辑完全不变只是存储结构变了。如果借阅记录特别多比如一天有100万条那么逐条处理的时间复杂度是O(n)已经是最优了。但可以优化的是内存访问模式如果书号范围很小数组是连续存储CPU缓存友好度高如果范围很大用哈希表会有哈希冲突的开销。实际工程中要根据数据规模选择合适的数据结构。如果还要支持多天统计、周报月报等汇总需求可以考虑用前缀和或者离线查询的方式预处理但这已经超出这道题的范畴了。6. 用Python重写一遍比较两种语言的思路差异考虑到很多读者用的不是C语言我也顺手写了一个Python版本。Python在处理这类题目时有一个天然优势代码短逻辑直观特别适合快速验证思路。n int(input()) for _ in range(n): last_borrow_time {} count 0 total_minutes 0 while True: line input().strip() if not line: continue parts line.split() book_id int(parts[0]) op parts[1] time_str parts[2] hour, minute map(int, time_str.split(:)) if book_id 0 and op 0: break time_minutes hour * 60 minute if op S: last_borrow_time[book_id] time_minutes elif op E: if book_id in last_borrow_time: count 1 total_minutes time_minutes - last_borrow_time[book_id] del last_borrow_time[book_id] if count 0: print(0 0) else: avg_minutes int(total_minutes / count 0.5) print(f{count} {avg_minutes})Python版本和C语言版本的核心思路完全一样但有几点差异值得注意Python的字典自带“判断键是否存在”的能力使用in操作符就能判断是否有借出记录不需要额外初始化-1这种哨兵值。del操作等价于C语言里的重置为-1。四舍五入这里用的是int(total_minutes / count 0.5)因为Python的/会得到浮点数int()是向下取整。Python的输入读取方式需要注意因为输入有多行每行末尾可能有换行用strip()清理一下比较保险。如果不清理split()也能处理因为默认按空白字符分割空字符串会被自动忽略。两种语言的核心逻辑一致但实现风格差异明显。C语言更接近底层需要自己管理状态和初始化Python更接近人类思维代码量更少。如果只是刷题验证思路我建议先用Python把逻辑跑通再用C/C写最终版本这样既快又稳。7. 最后的个人做题建议L1-043这道题我先后做过三遍。第一遍用C语言因为格式字符串的坑卡了很久第二遍用Python十分钟写完就过了第三遍是为了写这篇总结又回头认真梳理了一遍代码和边界条件。每次做都有新的收获。第一次学会了格式化输入里%c前空格的作用第二次体会到了高抽象语言带来的高效第三次真正理解了状态机思维在模拟题里的普适性。如果你正在准备PAT或其他OJ考试我的建议是不要在题解上花太多时间一定要自己动手写。写完以后拿我上面给的那组自测数据跑一遍再随机构造一些边界数据测一测。只有踩过这些坑考试时才能在几分钟内快速定位问题。如果这道题你已经能做对了可以试试把场景改一改比如改成停车场进出场计时、会议室预约计时、自习室座位占用计时练几次你就会发现原来所有这类题都是同一个套路状态记录加事件处理再加时间计算。掌握这个套路比背一百道题都管用。
返回列表