ARTICLE DETAIL

资讯详情

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

Java模拟算法实战:列车调度、网格机器人、约瑟夫环代码练习

Java模拟算法实战:列车调度、网格机器人、约瑟夫环代码练习 很多朋友刷算法题有个习惯先追动态规划、二叉树、图论这些“看起来有含金量”的题型觉得模拟题太简单、太直白考场上写出来也没什么成就感。但我在实际参与招聘和看历年笔试复盘时发现模拟算法恰恰是出场率极高且非常考验代码功底的一类。它确实不难想可一旦细节没照顾到轻则边界出错重则整段逻辑崩盘。尤其是 Java 选手写模拟题时还要时刻惦记集合选型、内存开销、循环终止这些基础功夫。这期内容我就围绕“Java 模拟算法题目练习”展开从题型识别、通用框架到列车调度、网格机器人、约瑟夫环这几类高频模拟场景逐个给出可运行的 Java 实现和踩坑复盘。不管你是刚学 Java 的初学者还是在准备面试八股、想补一补代码组织能力的进阶选手都可以照着练。1. 先读懂“模拟”这类题它真正考查的不是智商而是“翻译”能力1.1 模拟题和“抄代码”的区别很多人把模拟题理解成“照着题目描述把人家的过程写一遍”这话对了一半。真正的模拟题考查的是你能不能把一个用自然语言描述的场景准确翻译成程序语言。翻译过程中必须自己决定哪些变量需要记录哪些操作需要循环循环什么时候停下边界条件怎么判。举个例子。题目说“有一列火车依次进站进站后可能直接开出也可能停在站内等待后续列车请你判断某个出站序列能否实现”。你很快能想到用一个栈模拟站台但这只是第一步。真正决定成败的是“当前应该压入几号车”“何时开始弹出”这些动作的先后顺序。这比抄过程复杂得多本质上是考查你对“状态”的拆解能力。1.2 模拟题的常见变体面向过程、面向状态、面向事件我在练习时习惯把模拟题再细分一下这样更容易找到入手点面向过程的模拟像字符串替换、数组位移这类题每一步做什么写得很清楚你只要按步骤循环执行重点在于避免操作越界。面向状态的模拟像迷宫行走、电梯调度、自动机这类题场景里存在一个会在不同状态间切换的“对象”你要维护它的位置、方向、速度等属性并依据当前状态决定下一步动作。面向事件的模拟像任务调度、银行排队、消息队列这类题模拟过程围绕“事件发生顺序”展开通常需要配合优先队列或时间轴数组来维护下一个待处理事件。这三种变体没有绝对的边界很多题目会混合出现。比如列车调度表面上是栈的进出过程本质上其实是“事件序列判定”。认清变体之后你对题目结构的理解会清晰很多。1.3 为什么大厂笔试偏爱模拟题从面试官角度讲模拟题是性价比很高的筛选工具。一道稍微复杂的模拟题能同时看出你三方面能力能不能严谨地处理边界条件、会不会合理选择 Java 集合、代码写出来是否清晰易维护。相比之下纯考 DP 或图论反而容易因为“背过模板”而蒙混过关。从我个人做面试官的经验看模拟题翻车往往不是思路问题而是“写快了忘判空”“循环条件写错导致死循环”“用错集合导致并发修改异常”这类基础问题。这些恰恰是 Java 基础中最该被考察的部分。所以刷模拟题真的不是浪费时间它是在帮你夯实 Java 基本功和代码组织能力。2. 写模拟题前必须先搭好的三个架子输入域、状态表、终止条件很多人上来就动手写循环结果写了一半发现自己连“有多少种输入情况”都没想清楚。我自己的习惯是拿到任何模拟题先花三分钟把三个架子搭好。2.1 输入域先把“世界”约束出来别边写边猜模拟题总会给你一个“世界模型”比如网格的行列数、列车的数量、字符串的长度。你要做的第一件事是明确这些参数的范围和含义。如果网格是m x n那就得问自己坐标是从 0 开始还是从 1 开始越界条件写法是什么数组grid[x][y]的 x 是行还是列我见过太多人在这上面栽跟头尤其是行和列搞混时调试起来非常痛苦。所以拿到题目后我会先在注释里写清楚坐标约定比如// 这里统一用 (row, col)row 向下增加。这种习惯看起来很基础但在考场高压环境下它真的能救命。2.2 状态表把所有影响结果的变量列成清单模拟题的核心是“状态”。状态一变后续所有判断都会跟着变。例如模拟一台机器人扫地机器人的位置(x, y)、当前朝向dir、已经清扫过的格子集合visited这三个就是核心状态。我建议先画一张状态表列三列状态变量、初始值、何时更新。初始化不一定要写在纸上但至少要在脑中过一遍。比如方向dir 0表示向北L操作让dir (dir 3) % 4R操作让dir (dir 1) % 4。把这些定清楚代码结构自然就出来了。这个步骤还有个额外好处你可以提前判断哪些状态需要“去重”或“防死循环”。比如机器人可能陷入重复路径这时需要HashSet记录访问过的位置组合否则程序可能无限循环。2.3 终止条件模拟不等于死循环必须明确“游戏规则”模拟题最忌讳的就是“不知道什么时候停”。终止条件通常来自题目规则比如“直到队列为空”“直到所有操作执行完毕”“直到赛车到达终点”。这个条件一定要落实成循环条件而不是在循环体内靠break糊弄。举个例子约瑟夫环问题中终止条件是while (people.size() 1)列车调度中终止条件是“出站序列遍历完”或“无车可入栈且栈顶不是目标”。把这个想明白你的循环才不会变成脱缰的野马不会出现运行超时或者索引越界。3. 实战一列车调度 Java 实现——用栈模拟“先进后出”过程列车调度算是 Java 模拟题里很有代表性的一个热词榜上“列车调度 java”也常有人搜。它本身不复杂但能把栈、循环嵌套、边界判断这些点全串起来。3.1 原题与你的第一直觉先描述一下经典版本有编号为 1 到 n 的列车按 1, 2, 3, ..., n 的顺序依次进入调度站一个栈。调度站可以让列车直接出站也可以暂时存放。现在给定一个出站序列判断这个序列能否实现如果能输出入栈出栈操作过程。我第一次看到这题时的第一直觉是用栈模拟入站和出站逐个匹配目标序列。但很快发现关键不是“用不用栈”而是入栈动作什么时候发生。如果目标出站序列第一个是 3那我就得先把 1、2、3 全部压进栈然后弹出 3。如果当前该入栈已经入到 n 了而栈顶还不等于目标值那就说明序列不可能实现。3.2 关键设计栈、计数器和入栈时机代码设计大致这样用DequeInteger stack表示调度站这里我用ArrayDeque而不是老的Stack类因为Stack继承自Vector同步开销和性能都不理想ArrayDeque更轻量push/pop都是 O(1)。用int cur 1表示下一个等待入栈的车厢编号。遍历目标序列target[i]在需要时持续执行push直到栈顶等于目标值或者cur超过了 n然后尝试pop匹配则成功不匹配则宣告失败。为什么要用一个while而不是简单的if因为目标序列可能连续需要多个后续车厢入栈比如目标是3 2 1连续弹出时不需要再入栈但if会漏掉“先把前面车厢压完”的过程。用while才能忠实模拟“一直压到栈顶为目标”的动作。3.3 完整代码与分步讲解import java.util.ArrayDeque; import java.util.Deque; import java.util.Scanner; public class TrainDispatch { public static void main(String[] args) { Scanner sc new Scanner(System.in); int n sc.nextInt(); int[] target new int[n]; for (int i 0; i n; i) { target[i] sc.nextInt(); } DequeInteger stack new ArrayDeque(); int cur 1; // 下一个待入栈的车厢编号 StringBuilder sb new StringBuilder(); for (int num : target) { // 只要还没压到 n并且栈顶不等于目标值就继续压栈 while (cur n (stack.isEmpty() || stack.peek() ! num)) { stack.push(cur); sb.append(push ).append(cur).append(\n); cur; } if (!stack.isEmpty() stack.peek() num) { stack.pop(); sb.append(pop ).append(num).append(\n); } else { System.out.println(No); return; } } System.out.println(Yes); System.out.print(sb); } }这里的StringBuilder非常关键。模拟题经常需要输出大量操作步骤如果在每个动作时都用System.out.println会产生大量 I/O 开销在笔试平台里可能直接超时。正确做法是先攒到StringBuilder里最后一次性输出。这个细节在 Java 模拟题里很常见属于必会基础。3.4 试运行一个 5 列车的用例把过程写在纸上我强烈建议你拿到任何模拟代码后先手动跑一个小样例把每个状态变化写在纸上。下面我用n 5, target 3 2 1 4 5示范。初始栈空cur 1目标第一位是 3。目标 3cur1栈空压入 1cur2栈顶 1 ! 3压入 2cur3栈顶 2 ! 3压入 3cur4栈顶 3 3停止压栈。此时弹出 3。目标 2栈顶是 2直接弹出。目标 1栈顶是 1直接弹出。目标 4栈顶是 5不是此时 cur4栈空压入 4cur5栈顶 4 4弹出。目标 5cur5压入 5cur6栈顶 5 5弹出。最终输出Yes。整个过程栈里没有出现过目标值在栈顶但无法弹出的情况所以序列合法。如果你在纸上画出每次 push / pop就会理解为什么循环条件是stack.peek() ! num且还要限制cur n。少了cur n这个条件当所有车厢都压完但目标还没匹配时while会继续尝试压入并不存在的车厢。4. 实战二网格机器人模拟——把“方向感”拆成可测试的状态列车调度练的是栈和线性过程的模拟接下来我们换一个场景网格上的机器人控制。这类题在笔试里出现频率更高因为结合了坐标运算、方向变化和障碍判断非常考验对状态的维护能力。4.1 问题定义避开障碍、按指令走假设有一个rows x cols的网格部分格子的值为 1 表示障碍物不能进入。机器人从(0, 0)出发初始方向朝东。给定一串由L、R、M组成的指令其中L表示左转 90 度R表示右转 90 度M表示沿当前方向前进一格。要求计算执行完所有指令后机器人的最终坐标如果前方是障碍物或越界则本次M不移动停在原地继续执行后续指令。这道题的关键点在于方向必须拆成索引或坐标增量来算而不能靠一堆if (dir.equals(EAST))硬编码否则代码会又长又容易漏。4.2 状态维护横纵坐标、方向索引、visited 集合这里的状态其实有三块当前位置(x, y)注意我用x表示行、y表示列务必全程统一。当前方向dir用一个整数0, 1, 2, 3分别表示东、南、西、北。如果需要防止原地绕圈或记录清扫路径还要一个HashSetString visited来记录x , y。为什么用整数表示方向而不是枚举因为在模拟题里L和R本质上是对方向索引做1/-1的取模运算。写成dir (dir 1) % 4和dir (dir 3) % 4代码既简短又不容易漏判。4.3 使用 Java 代码实现的核心循环public class RobotSimulation { public static void main(String[] args) { int rows 5, cols 5; int[][] grid { {0, 0, 0, 0, 0}, {0, 1, 1, 0, 0}, {0, 0, 0, 1, 0}, {0, 1, 0, 0, 0}, {0, 0, 0, 0, 0} }; String ops MRRMMLMRM; int x 0, y 0; int dir 0; // 0 东, 1 南, 2 西, 3 北 int[] dx {0, 1, 0, -1}; int[] dy {1, 0, -1, 0}; for (char c : ops.toCharArray()) { if (c L) { dir (dir 3) % 4; // 左转 } else if (c R) { dir (dir 1) % 4; // 右转 } else if (c M) { int nx x dx[dir]; int ny y dy[dir]; if (nx 0 nx rows ny 0 ny cols grid[nx][ny] 0) { x nx; y ny; } } } System.out.println(( x , y )); } }方向数组dx/dy的顺序必须和dir的语义严格对应否则一改方向就走错。我在练习时习惯把这种映射关系写在注释里防止隔天自己都看不懂。4.4 处理隐藏的 bug死循环、重复路径、转向次数这类网格模拟题最常见的 bug 有三个。第一个是越界判断写成nx rows却漏了nx 0导致机器人从左边“穿墙”。这种错误靠肉眼很难发现建议在模拟每个M之前先用纸笔推导一轮或者临时打印nx/ny观察。第二个是没有考虑“前方是障碍物就停在原地”。题目不会强调这一点但实际规则里它非常重要。如果你看到障碍物还继续移动最终答案会错。第三个是方向转换次数过多导致索引变化错误。比如连续执行L、R、L如果你每一步都重新算绝对值很容易出错。用% 4取模之后天然就能处理任意次转向。另外如果你需要判断机器人是否进入循环不要只记录位置还要把方向一起记进HashSet。因为同一个位置可能朝不同方向经过属于不同状态。我遇到过一道题机器人会在一条直线上来回走如果不记录方向就会误判成死循环。5. 实战三约瑟夫环——从并发修改看 Java 集合的“隐藏规则”第三个经典模拟题是约瑟夫环。这个题在面试里经常变着花样出现比如“n 个人围成一圈每数到 m 就淘汰一人问最后留下的是几号”。它本身是个数学题有 O(n) 的递推解法但在面试场景里面试官往往想看的不是数学结论而是你会不会老老实实地做模拟以及知不知道 Java 中删除集合元素的正确姿势。5.1 为什么选这类题因为约瑟夫环是“边遍历边删除”的典型场景。很多人用ArrayList在循环中直接remove结果要么索引越界要么出现并发修改异常要么删错了人。刷这道题可以很好地检验你对迭代器、集合底层实现和复杂度模型的理解。5.2 用 LinkedList 实现安全删除如果你直接用ArrayList模拟有一点特别难受每次移除一个人后续所有元素都要向前移动复杂度是 O(n)n 大一点就非常慢。LinkedList删除节点时只需要修改指针表面上更好但如果你用get(index)找节点那么随机访问又是 O(n)。所以实际使用时要结合题目规模去取舍。下面这段代码是维护“当前应被移除下标”的常见写法import java.util.LinkedList; import java.util.List; public class Josephus { public static void main(String[] args) { int n 7; int m 3; ListInteger people new LinkedList(); for (int i 1; i n; i) { people.add(i); } int idx 0; while (people.size() 1) { idx (idx m - 1) % people.size(); people.remove(idx); // 注意: 移除后idx 指向下一个元素不用再 1 } System.out.println(people.get(0)); } }这里的核心是idx (idx m - 1) % people.size()。为什么要减 1因为idx本身指向当前起点报数时要把自己也算进去。比如从 0 号开始报数m3 时报数 1、2、3 对应下标 0、1、2所以要加m-1而不是m。5.3 复杂度对比何时该换数组很多人以为用LinkedList做约瑟夫环就一定比ArrayList快。其实不一定。删除动作本身链表是 O(1)但为了找到第 k 个节点链表要遍历 k 步总体是 O(nm)。而ArrayList的随机访问虽然是 O(1)但删除时移动元素的开销也是 O(n)总体同样是 O(nm)。规模不大时两个都能跑但ArrayList的常数通常更小因为数组的内存局部性好。所以我的建议是如果 n 很大、m 也很大别硬用模拟去推导数学递推公式如果只是面试官让你做基础模拟那就用LinkedList配合“游标下标”这种直观写法并主动说清楚复杂度。这样既能展现你懂集合底层又能体现复杂度意识。5.4 顺带提一下并发修改异常在写模拟题时很多人会写出这样的代码for (Integer p : people) { if (xxx) { people.remove(p); } }这行代码几乎必然触发ConcurrentModificationException因为for-each底层是Iterator而迭代过程中集合结构被修改了。解决办法有两种一是用Iterator的remove()方法二是像我上面一样手动维护下标并在循环体中“后移”或“重置”下标。手动维护下标时要特别小心 remove 后下标指向的位置否则会跳过一个元素。6. 从模拟题延伸到 Java 八股面试现场如何讲明白思路很多人觉得模拟题没啥好讲的直接写代码就行。但面试官真正想看到的是你分析问题的过程。我总结了两个实用技巧一套两分钟分析套路、一张集合选型对照表。6.1 拿到题 2 分钟的分析套路面试时拿到一道模拟题我不会马上写代码而是按固定顺序说一遍圈出对象这个场景里有哪些“会发生状态变化的东西”机器人、列车、排队的人、任务。列出状态变量每个对象的哪些属性会变位置、方向、剩余数量、当前时间。找动作规则什么条件下状态会变遇到障碍、栈顶匹配、报数到 m。找终止条件循环什么时候停队列空、序列遍历完、只剩一个人。估算复杂度最坏情况下循环执行多少次会不会超时如果超时哪些状态可以合并或去重。这套话术不复杂但能把你的思路清晰地暴露给面试官也能帮你避免漏掉条件。我见过很多候选人代码写对了但讲不出“为什么用 HashSet”“为什么这里不超时”这种回答在面试里会很吃亏。6.2 模拟慢怎么办数据结构降重的两种姿势模拟题超时最常见的两个原因就是循环次数太多或者状态重复计算。解决办法有两个方向。状态去重如果模拟过程会反复经过同一状态用HashSet或HashMap记录访问过的状态遇到重复就走另一条逻辑或者直接终止。比如网格机器人绕圈、搜索迷宫死路都适合这么做。事件驱动代替时间片轮询有些模拟天然不是“一步一步走”的比如多个人同时排队、多个任务同时调度。如果按“每秒推进一步”做效率太低正确做法是用PriorityQueue维护“下一个最早发生的事件”每次只处理最近的事件。这里的事件可以是“某个人完成服务”“某列车到站”本质上是一种按时间跳转的模拟。6.3 常见 Java 集合选型对照表我把刷模拟题最常用的集合整理成一张表面试前过一遍会非常有帮助场景首选容器底层结构关键优势栈结构、先进后出ArrayDeque动态数组push/pop O(1)非线程安全更轻量队列结构、先进先出ArrayDeque动态数组offer/poll O(1)支持双端模拟淘汰、中部删除LinkedList双向链表删除节点不需要大范围搬移随机访问 尾部增删ArrayList动态数组get O(1)扩容摊还 O(1)按优先级取任务PriorityQueue二叉堆堆顶 O(1)插入 O(log n)状态去重、快速查找HashSet哈希表平均 O(1) 查找映射状态到值HashMap哈希表 链表/红黑树灵活维护复杂状态信息注意LinkedList在随机访问时是 O(n)所以如果你频繁get(i)又频繁删除实际可能比ArrayList更慢。选容器要看“主要操作”是什么不能只看“删除”或“查找”单点。6.4 值得反复练习的模拟题清单面试前我建议把下面这几类模拟题刷一遍不用贪多吃透典型套路更重要机器人模拟原地转向、越界判断、带障碍移动。栈模拟括号匹配、表达式求值、列车调度。队列模拟窗口排队、消息队列、生产消费场景。环形结构模拟约瑟夫环、出队入队轮流操作。事件驱动模拟任务调度、会议安排、CPU 进程切换。数组位移模拟旋转矩阵、扫雷展开、字符串消除。如果时间有限优先精刷前四类它们覆盖了 90% 的基础模拟模式。7. 我的调试习惯与最常翻车的三个细节模拟题的代码通常不短一旦出错靠干瞪眼很难发现问题。我自己的调试习惯是在小数据上逐行打印状态图。比如列车调度每执行一次 push 或 pop就打印当前栈的内容和cur的值网格机器人就打印每次移动后的(x, y)和dir。只要状态一错马上能定位到是哪个分支逻辑出了问题。最后再分享三个我反复踩过的坑希望你避开。第一个是忘记初始化。模拟题中的计数器、方向索引、当前下标经常要初始化为 0 或 1搞混一个就全盘皆输。我的习惯是在声明变量的同时就把初始值写进注释比如int cur 1; // 下一辆等待入栈的车厢编号。第二个是循环体内修改了不该动的变量。比如约瑟夫环里 remove 之后继续让idx就会跳人。这种错误特别隐蔽因为单看代码逻辑还挺通顺。解决办法是写完之后用最小用例走一遍看每一步坐标和下标是否符合预期。第三个是直接用字符串拼接输出而不是 StringBuilder。模拟题动辄输出上千行操作如果每条都用System.out.println不是逻辑错而是性能分被扣光。Java 面试中对 IO 性能的敏感度也是考察点这个细节别丢。模拟算法的魅力就在于它不要求你掌握多么高深的数学结论但它能逼你把每一行代码都写踏实。练好模拟题你的 Java 基础、代码组织能力、边界控制能力都会实打实地提升这比背一百道八股文都管用。
返回列表