华为OD机考C卷:手机App防沉迷系统Java题解与实战
上周刚把华为OD机考C卷考完抽到的正是手机App防沉迷系统这道题Java语言双机位全程盯着说实话压力比平时刷题大不少。这道题本身不是那种让你背红黑树、写线段树的难题但它把“时间区间 优先级抢占 同刻事件”三个点揉在一起非常考验状态建模能力。以下我把题目拆开讲清楚并把可提交的Java代码完整放出来。想进OD的、正在刷华为OD机试题的、或者准备蓝桥杯这类模拟题的同学这篇文章应该能帮你省下不少时间。1. 华为OD机考先搞清楚考试形态再谈怎么答C卷1.1 机考在整个招聘流程里的分量很多人问“华为OD好进吗”我的体会是它不是那种随便写两笔就能过的门槛但也没有难到需要准备竞赛级算法的程度。OD的招聘流程一般是机考、性格测试、技术面试、HR面试、综合面试这么几步机考是第一关也会直接影响后续定级和薪资谈判。机考成绩好后面面试时你的容错率就高很多机考翻车简历再漂亮也很难捞回来。机考常见的安排是2~3道编程题总时长大概150分钟左右具体看批次和试卷类型。A卷、B卷、C卷其实没有本质差别主要是题单不同。C卷给我的感觉是更偏“工程模拟题”喜欢在某个业务场景里藏几条隐含规则手机App防沉迷系统就是典型的代表。这类题不会直接告诉你“请用优先队列”而是让你自己从场景里抽象出模型所以平时刷算法题多的人反而不一定第一时间转得过来。机考的判分一般看用例通过率不是只看最后答案是否AC。也就是说部分用例通过也有分。明白这一点很重要考试时先保证把能过的用例全过掉再回头优化复杂情况比死磕一道题要划算得多。我自己考这套C卷时就是先把防沉迷系统按最简单的思路写完再逐步补边界稳扎稳打通过。1.2 双机位监考的现场规则与合规准备双机位是华为OD机考的标配。所谓双机位就是一台电脑摄像头对着你的正脸和屏幕另外用一台手机或摄像头放在侧后方拍到你的手部、桌面和电脑屏幕。两个画面会同时录制后台还会有人工抽检和AI行为分析所以整个考试过程最好不要做任何可疑动作比如频繁低头、反复切屏、长时间离开座位。合规准备的细节我踩过一次坑提醒大家注意手机机位一定要提前架好并插上充电器。考试时间不短手机没电导致监控中断这种情况非常影响心态。手机画面要能覆盖到键盘和手部不要只拍个背影。我会把手机用支架固定在身体左后方约45度、距离桌面半米左右的位置调整到画面里能看到屏幕下边缘、键盘和手。考试前把电脑上的弹窗通知、聊天软件、浏览器提醒全部关掉。不要手动切屏更不要试图打开其他文档规则就是规则没必要在这种事上冒险。网络用有线或者稳定的WiFi考试前半小时测一次网速。断网重连虽然可以恢复但中间的时间损失和心跳加速的体验真的不值。双机位不是用来吓人的它保证的是考试公平。你按流程正常准备把桌面清空、设备调好、心态放平剩下的就交给平时积累的代码能力。1.3 C卷的题型结构与刷题方向C卷的题型通常会有梯度第一题偏简单可能是字符串处理、数组操作、简单排序第二题是中等题往往就是防沉迷系统这种“模拟 排序 优先级”的综合题第三题难度再上一档可能是搜索、动态规划或复杂图论。防沉迷属于第二题的位置分值占比不低而且特别容易因为边界条件丢分。刷题方向我建议重点练三类时间区间处理时间字符串解析成分钟数、区间合并、区间重叠判断。这类技能在很多场景题里都能复用。模拟类题目尤其是“有多个对象同时运行需要按规则选一个”的模型本质上就是状态机。排序规则的自定义Java里Comparator怎么写、Lambda排序怎么用一定要练到条件反射。还有一个小点搜索热词里常看到“常用库函数algorithm java”其实就是想找Java的排序和集合工具。Java里对应的是Arrays.sort、Collections.sort、PriorityQueue、ArrayList这些。平时把这些API用熟考试时就不用一边写代码一边翻记忆。2. 手机App防沉迷系统题目到底在说什么2.1 题面翻译与输入输出格式机考平台上的题面一般写得比较长会包装成“某手机管家推出防沉迷功能”之类的背景。去掉壳子之后核心需求是这样有N个App每个App有名称、优先级、一个当天内的开始时间和结束时间时间格式是HH:MM。在任意时刻如果有多个App同时处于“使用时间段内”系统只会选择其中优先级最高的那个记为该App正在使用。如果优先级相同则开始时间更早的那个优先。高优先级App一旦开始运行低优先级App会被强制暂停等高优先级App结束后之前被暂停的App继续被考虑。最终要输出每个App在一天内的累计使用时长单位是分钟。输入的第一行是App数量N接下来N行每行格式为App名称 优先级 开始时间 结束时间例如3 WeChat 5 08:00 10:00 Game 3 09:00 11:00 Music 1 09:30 12:00输出要求按输入顺序每行输出App名称和累计使用分钟数比如WeChat 120 Game 60 Music 60这里优先级具体是“数字越大越优先”还是“数字越小越优先”不同批次可能不同我这次的试卷明确写了“数字越大表示优先级越高”范围0到5。你拿到题先把这个圈出来再动手别想当然。2.2 三条核心规则优先级压制、同优先先启动、同刻先关后开这道题核心规则有三条每条都藏坑。第一优先级压制。高优先级App运行时低优先级App虽然已经启动但不会累积使用时长。低优先级App的“开始使用”要等到所有高于它的App都结束后才能恢复计算。这里最容易误解的是低优先级App被强制暂停后不需要重新执行“开始”动作只要压制它的App关闭它自动继续使用。第二同优先级先启动。如果两个App优先级一样那就比较谁先开始。注意是比较“原始开始时间”不是比较“重新恢复的时间”。一个App被高优先级压制等它恢复后它的优先级比较依据仍然是它最初那个启动时刻。这一点在代码实现里特别容易写错。第三同一时刻先关后开。当某个App的结束时间和另一个App的开始时间落在同一分钟时要先处理结束再处理开始。比如A在10:00结束B在10:00开始那么10:00这一分钟应该属于已经结束的A的最后一个使用分钟还是属于B按题目语义A使用到10:00截止B从10:00开始生效两者在边界上“交接”。实现时如果不统一处理顺序很容易让这段零分钟或一分钟的归属出问题。2.3 用三组样例手动推演结果样例一是开头那组。我按时间轴推一遍08:00到09:00只有WeChat在运行WeChat使用60分钟。09:00Game启动但WeChat优先级5大于Game的3所以09:00到09:30仍然是WeChat使用30分钟。09:30Music启动优先级更低仍然被压制09:30到10:00还是WeChat使用30分钟。10:00WeChat结束此时Game优先级3大于Music的1所以10:00到11:00由Game使用60分钟。11:00Game结束Music终于开始真正使用11:00到12:00使用60分钟。最终WeChat累计120Game累计60Music累计60。再看同优先级先启动的例子2 A 5 09:00 11:00 B 5 09:30 12:00A先启动虽然B优先级和A相同但B只能等着。A在09:00到11:00独占120分钟B在11:00到12:00使用60分钟。输出就是A 120、B 60。最后看同刻交接的例子2 A 5 09:00 10:00 B 5 10:00 11:00A在09:00启动B在10:00启动两个优先级相同但启动时间不同。A使用09:00到10:00这60分钟B使用10:00到11:00这60分钟。看起来简单但如果你在同一时刻处理事件时把顺序搞反可能让B提前进入活跃集合在10:00那一瞬间抢夺A的最后一段时长。所以“同刻先关后开”不是强迫症是保证正确性的必要手段。3. Java实现事件流 活跃集合的完整解法3.1 为什么想到用“事件 活跃集合”而不是直接模拟每分钟一天只有1440分钟理论上可以每分钟扫描一次找出当前该运行的App。这种方式在N很小的时候也能跑出结果但问题在于代码写出来很啰嗦而且每一分钟的扫描都在重复计算逻辑不清晰。更重要的是这种写法遇到时间精度变成秒级、或者跨天场景时就废了。我采用的做法是事件驱动每个App的开始时间和结束时间都是“事件点”。在相邻两个事件点之间活跃App集合没有变化所以这段时间的使用者不会变。因此只需要在事件点处理时更新一次状态再把上一个事件点到当前事件点之间的时长累加给当前使用者就行。活跃集合我用一个ArrayList保存所有“当前已启动且未结束”的App每次分配时长时线性扫描找出真正占用屏幕的那个。虽然优先队列也能做但ArrayList在这个场景下更直观移除元素、观察活跃状态都很方便。N不超过50线性扫描完全没问题代码还更好读。3.2 完整Java代码可直接提交下面这段代码是我考后清理过的版本可以直接在华为OD机考平台上提交。类名用Main不要加package使用Java 8及以上都可以。import java.util.*; public class Main { static class App { int id; String name; int level; int start; int end; int usage; App(int id, String name, int level, int start, int end) { this.id id; this.name name; this.level level; this.start start; this.end end; this.usage 0; } } static class Event { int time; int type; // 0表示结束1表示开始 int id; Event(int time, int type, int id) { this.time time; this.type type; this.id id; } } static int toMinute(String s) { String[] t s.split(:); return Integer.parseInt(t[0].trim()) * 60 Integer.parseInt(t[1].trim()); } static App pickBest(ListApp active) { App best active.get(0); for (App a : active) { if (a.level best.level || (a.level best.level a.start best.start) || (a.level best.level a.start best.start a.id best.id)) { best a; } } return best; } public static void main(String[] args) { Scanner sc new Scanner(System.in); int n Integer.parseInt(sc.nextLine().trim()); ListApp apps new ArrayList(); ListEvent events new ArrayList(); for (int i 0; i n; i) { String line sc.nextLine().trim(); String[] parts line.split(\\s); String name parts[0]; int level Integer.parseInt(parts[1]); int start toMinute(parts[2]); int end toMinute(parts[3]); apps.add(new App(i, name, level, start, end)); events.add(new Event(start, 1, i)); events.add(new Event(end, 0, i)); } // 时间升序同一时间结束事件排前 Collections.sort(events, (a, b) - { if (a.time ! b.time) { return a.time - b.time; } return a.type - b.type; }); ListApp active new ArrayList(); int lastTime 0; int i 0; while (i events.size()) { int t events.get(i).time; // 先把 [lastTime, t) 这段时长累加给当前占用者 if (!active.isEmpty()) { pickBest(active).usage t - lastTime; } // 找到同一时刻的事件批次 int j i; while (j events.size() events.get(j).time t) { j; } // 同一时刻先处理结束再处理开始 for (int k i; k j; k) { if (events.get(k).type 0) { active.remove(apps.get(events.get(k).id)); } } for (int k i; k j; k) { if (events.get(k).type 1) { active.add(apps.get(events.get(k).id)); } } lastTime t; i j; } for (App a : apps) { System.out.println(a.name a.usage); } } }3.3 核心逻辑逐段拆解与复杂度说明这段代码看起来不长但几个细节一定要讲透。读取数据时我用split(\\s)比split( )更稳因为题目输入里可能出现多个连续空格。时间解析统一转成分钟数所有时间比较都基于整数避免字符串比较的边界问题。事件排序时我把同一时刻的结束事件排在开始事件之前。这一步是“同刻先关后开”的关键。a.type - b.type让type为0的结束事件排到type为1的开始事件前面。排序后events数组就变成一条按时间流动的状态变化序列。while循环每次处理一个“时间批次”。假设当前批次时间是t那么先看active集合里当前占用者是谁把从lastTime到t之间的时长加给它。这里有一个很容易被忽视的点如果active为空说明这段时间没有任何App运行直接跳过累加但不能忘了把lastTime推进到t。批次内部分两步走第一步遍历所有结束事件把对应App从active里移除第二步遍历所有开始事件把对应App加入active。为什么不能合并成一步因为如果同一时刻既有结束又有开始先加后删或者先删后加都可能让中间状态出现错误尤其是当结束和开始涉及同一个App时用引用移除和加入的顺序不对会导致集合里残留或丢失元素。用“先全部删、再全部加”的方式可以保证同一时刻的状态切换是原子的。pickBest方法选当前占用者时比较的是优先级降序、原始开始时间升序、输入序号升序。这个顺序就是题目的三条规则叠加。特别说明一下id比较如果两个App优先级相同且开始时间相同输入序号小的获胜。机考一般不会把这种极端情况当作区分点但加上这个比较可以让结果更稳定。复杂度方面事件数量是2N排序是O(N log N)while循环最多处理2N次每次pickBest线性扫描active最坏情况下active里同时存在N个App所以整体是O(N^2)。N不超过50时这个复杂度完全可以接受。甚至直接模拟1440分钟再在每分钟线性扫描一次也是O(N*1440)仍然能过。但事件流写法的好处是逻辑严谨扩展性强万一以后题目把时间线段改成跨天也只是改个数据范围的问题。4. 机考实战我踩过的坑和复盘的解决方案4.1 最容易丢分的边界问题我第一版提交的代码样例全过但自定义用例错得离谱原因就是下面这几个坑。优先级方向读反。这个听起来蠢但考场上真的会发生。我平时练的版本是数字越大优先级越高但有些题面喜欢写0级最低5级最高看起来一样其实等价于大数优先如果哪天遇到反过来“数字越小越优先”的变体代码里的比较符号全都要反转。一定先审题。同优先级“启动时间”用错。我一开始在App类里记录了一个joinTime表示它什么时候加入active集合。被高优先级App压制后它被移出active之后再次加入时joinTime就会更新成恢复时间。这样一来当它和别的App比较优先级相同时会被误判为“新启动”导致先启动的App反而被压后。这是非常隐蔽的bug。正确做法是永远用最初输入的start时间参与优先级平局比较。24:00边界。结束时间可能是23:59也可能某些变体里出现24:00。解析24:00会得到1440分钟正好可以代表一天结束。我的事件流不会漏掉1440这个事件所以不存在问题。但如果你用的是“每分钟扫描”的写法需要额外注意最后这一分钟。事件批次处理漏分配时长。漏写lastTime t是新手高频失误。一旦漏掉后续所有区间都会向前偏移结果全部错乱。这个赋值语句看起来不起眼却是整个时间轴的锚点。4.2 在线OJ平台的输入输出与运行细节华为OD机考的平台和普通OJ有些差别但大体一致。有几个细节值得提。类名必须叫Main不能有package声明否则平台编译直接报错。代码里有中文注释一般没问题但保险起见注释也尽量写英文或简洁中文避免个别平台编码问题。Scanner读取时读完N之后那一行最好trim()一下再解析。有些平台输入的每行末尾会带空格Integer.parseInt(sc.nextLine().trim())能避免隐藏的NumberFormatException。后续每行App信息同理。输出不要带任何提示文字每行格式严格是“名称 分钟数”。多余的空格、换行、制表符都可能导致比对失败。我习惯最后输出时用System.out.println(a.name a.usage)不要在行尾追加空格。Java版本问题也要注意。在线平台一般支持Java 8lambda表达式和Comparator.comparing都没问题但一些实验性API别用。我写代码时尽量不用var、List.of这类偏新的语法免得平台编译环境不认。4.3 考前一周怎么利用这类题目高效冲刺如果你时间紧只有一周我建议按这个节奏来每天挑三道“模拟 排序”类的题目限时45分钟做完。不用刻意挑战特别难的数据结构题中等题的命中率和稳定性才是提分关键。这段时间你会把Collections.sort、ArrayList增删操作、Scanner读取这些基本功夫练成肌肉记忆考试时才能不卡壳。每一道题做完后至少构造两组自己的边界用例。比如所有App时间完全不重叠、所有App时间全部重叠、同一时刻多个App同时结束和开始。不要直接抄样例样例只是给你兜底用的真正查错的是自定义用例。机考环境适应也很重要。双机位意味着考试时不能像本地IDE那样随时翻资料所以考前要模拟“无补全、无搜索”的状态。我在考前三天开始用记事本写Java不开IDE提示写完再贴回IDE编译。这个过程很痛苦但非常有效考场上不会再因为不记得某个集合方法的签名而卡壳。时间分配方面拿到卷子先把三道题都扫一遍评估难易。我这次就是先花10分钟把防沉迷系统的解题思路在草稿纸上列出来确保主逻辑没有遗漏再开始敲代码。简单题先拿分中等题吃透困难题尽量贪分。这个策略在OD机考里比较实用。最后再分享一点个人体会这道题做完之后我最大的收获不是“会用ArrayList了”而是明白了机考模拟题的核心是把业务规则翻译成编程模型。优先级压制对应比较器的设计同刻先关后开对应事件排序的顺序累计使用时间对应区间分配。每一句话都能落地成代码这才是这类题真正想考察的东西。最后再分享一个小技巧如果提交后还有富余时间可以在代码里临时加一段System.err.println打印每个事件点的时间、事件类型、当前占用者肉眼对一遍时间轴。很多隐藏错误在输出时间里一眼就能看出来比干瞪代码高效得多。希望看到这里的你也能顺利通过OD机考拿下这套C卷。