
PTA的Java题单里有一道题叫“爬动的蠕虫”题号7-17。我第一次看到它的时候真没当回事一条虫子在井底每分钟往上爬U寸爬一分钟休息一分钟休息的时候往下滑D寸问多久能爬出去。这看起来用小学数学就能算。结果我第一版代码提交上去直接WA而且我盯着屏幕想了半天都没想明白哪里错了。后来我翻了翻身边的讨论发现这道“简单题”卡住的人还不少问题几乎都出在同一个地方——没有把蠕虫的动作拆成“一步步发生”的过程而是想当然地按匀速运动或两分钟一个周期去套。这篇文章就把这道题从头到尾拆开讲清楚为什么它会坑人、怎么用最稳的方式写对、数学解法有什么边界坑以及做完这道题你能顺带带走的模拟题通用思维。先说结论这道题考的不是公式也不是Java的什么高级特性而是“状态推进”和“循环退出条件的判断时机”。只要你把每一分钟当成一个独立的事件在事件发生的那个瞬间检测条件基本不会错。下面我按自己重新推演题目的过程来写。1. 题目还原与真正的坑点蠕虫不是匀速运动1.1 先把题目过程完整演一遍题目给三个正整数N、U、DN是井深U是蠕虫每分钟往上爬的距离D是它休息时每分钟下滑的距离并且保证D U。蠕虫在井底位置可以看作0井口位置就是N。每一分钟它要么爬行要么休息。第1分钟爬行第2分钟休息并下滑第3分钟再爬行第4分钟再休息并下滑……直到某个瞬间它爬出井口即当前高度大于等于N的时候计时停。拿最经典的样例来说N3、U2、D1。第1分钟位置从0爬到2还没到3不能出去。第2分钟休息位置从2滑到1。第3分钟再爬位置从1到3刚好碰到井口第3分钟结束输出3。这就是完整过程。注意一个关键事实蠕虫只可能在“爬行那一分钟的结束瞬间”到达或超过井口绝不可能在“休息并下滑”的瞬间爬出去因为下滑只会让位置更低。这个事实看起来很废话但很多错误代码就是栽在这上面。1.2 为什么这题容易在PTA上翻车我观察到的翻车原因排在第一位的是“把休息和爬行合并成一个周期然后直接每隔两分钟算一次位置”。比如有同学这么写先把U加到高度上再把D减掉把这当成一个完整循环最后才判断height是否大于等于N。这样做在N特别接近井口的那个“奇数分钟”会把已经到达井口的状态漏掉然后白白多算一个下滑最后结果偏大甚至在某些数据下死循环。排在第二位的是“没搞清楚初始动作”。蠕虫是从井底开始先爬一分钟而不是先休息一分钟。有些代码把下滑放在最前面等于把蠕虫起点算到了0以下结果当然不对。第三位则是把题目当数学题试图用一个诸如(N-U)/(U-D)的公式直接算答案结果在“不整除”和“N小于等于U”这类特殊数据上翻车。这道题本身是PTA Java基础题单里很典型的一题用到的东西只有Scanner输入、while循环、if判断和最基本的整数运算。但它的思维模型即“每次事件发生后立刻检查是否满足结束条件”是后面所有模拟类题目的雏形。你后面做天梯赛L1/L2里的那些模拟题遇到“机器人走路”“扑克牌发牌”“电梯上下”这类题目用的都是同一套思维。2. 最稳的模拟解法把每一分钟当成独立事件推进2.1 先给一套可以直接提交的代码我先给结论版代码。这是我认为最不容易写错的一版每次循环里先让蠕虫爬一分钟立刻检查是否出井如果没出去再让它休息下滑一分钟然后继续下一轮循环。import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner in new Scanner(System.in); int n in.nextInt(); int u in.nextInt(); int d in.nextInt(); int pos 0; // 当前位置初始在井底 int time 0; // 经过的分钟数 while (pos n) { // 第1分钟爬行 time; pos u; // 立刻检查爬完这一分钟是否已经到井口 if (pos n) { break; } // 还没到那接下来必须休息一分钟并下滑 time; pos - d; } System.out.println(time); } }这段代码的核心逻辑是把“爬行后判断”和“休息后不判断”分开。爬行后必须判断因为这是唯一可能出井的时机休息后不需要判断因为下滑只会让位置变小永远不可能让蠕虫到达井口。你把这个顺序记牢这道题就成功了一大半。2.2 另一种写法用分钟序号判断当前是爬还是滑有的同学喜欢把循环写成“每一分钟都判断一次当前该爬还是该滑”用time % 2来区分奇偶分钟也是对的。代码长这样int pos 0; int time 0; while (pos n) { time; if (time % 2 1) { pos u; } else { pos - d; } } System.out.println(time);注意time从1开始所以奇数分钟爬偶数分钟滑。这种写法也能AC因为如果某一轮是“下滑”那么下滑后pos只会更小循环条件pos n依然成立不会错误退出如果某一轮是“爬行”且已经到达井口循环条件不满足自然退出。它和2.1那版在结果上完全等价。但我个人强烈推荐2.1的写法原因很实在2.2的写法把“爬行判断”和“休息下滑”搅在了一起读代码的人要额外花精力去理解奇偶性和退出时机。而2.1的代码结构本身就是题目的事件序列你顺着读一遍就是在看蠕虫的生命历程排查问题的时候成本低很多。尤其对新手可读性比少写几行代码重要得多。2.3 用自己的代码跑几组数据验证一下手感我建议你拿到这段模拟代码后亲手去跑下面这些数据并且用手算复核一遍这样你对“判断时机”会有更直观的感觉。NUD模拟输出过程简述32132 - 1 - 31211第1分钟直接爬到2已经出井53133 - 2 - 5100109181每两分钟净上升1寸最后第91次爬行正好到100最后一组数据值得单独说。U10、D9时每两分钟净上升只有1寸前面90个两分钟周期让人感觉永远爬不完。但实际到第91次爬行时位置刚好等于100时间就是2×91-1181分钟。如果你写代码时用的是“先让蠕虫进一个完整两分钟周期再判断是否出井”的思路这个数据有很大概率算错因为第181分钟那次爬行到达井口后你的代码可能还会强行再算一次下滑把结果变成182甚至更多。3. 数学公式与模拟之争一行算出答案真的划算吗3.1 严格推导一个可用的公式模拟代码虽然稳但总有同学觉得不够“高级”想用一个公式直接算出答案。这里我承认确实存在一个相对严谨的公式但推导过程比大多数人想象的要绕。设蠕虫一共经历了k次“爬行动作”后第一次到达或超过井口。在前k-1次爬行之后它都各经历了一次休息下滑所以第k次爬行结束时的位置是position k × U - (k-1) × D要让这个值大于等于N有k × U - (k-1) × D N整理一下k × (U - D) D N所以k (N - D) / (U - D)k取满足条件的最小正整数整个用时就是2k - 1因为最后一次爬行结束后不需要再休息。用Java写出来是这样int k (int) Math.ceil((n - d) * 1.0 / (u - d)); if (k 1) { k 1; } int ans 2 * k - 1; System.out.println(ans);这里必须注意两点第一Math.ceil的参数如果直接写int型除法会先做整除导致小数被截断所以要用*1.0转成double第二当N D时n-d可能是0或负数Math.ceil可能得到0或负值所以要加一个if (k 1)的保险。这个公式能通过绝大多数测试数据但你需要承认它并不比模拟代码更好理解。3.2 网上常见的“简化公式”为什么容易挂我在不少同学的代码里看到过这种写法int ans (n - u) / (u - d) * 2 1;它的思路大概是先花1分钟爬到U的高度之后每两分钟净上升U-D最后再花1分钟爬出去。这个思路本身有个隐含前提——N必须大于U而且N-U必须能被U-D整除。一旦遇到不整除的情况整数除法会直接向下取整结果偏小如果换用浮点数再乘以2再加1又会因为没向上取整结果可能偏大或偏小。我举一个具体例子N6、U3、D1。正确答案是5分钟第1分钟爬到3第2分钟滑到2第3分钟爬到5第4分钟滑到4第5分钟爬到7到达井口。套上面的简化公式先算(6-3)/(3-1)等于1整数除法再乘2加1结果是3明显不对。就算你用double算(3.0/2)*21也等于4还是不对。原因就是最后一次爬行的起始位置并不是刚好卡在“一个完整周期末尾”的位置简化公式没有覆盖这种中间状态。这个例子很好地说了一件事数学公式只有在“模型完全匹配”时才是捷径一旦边界条件没讨论清楚公式本身就是最大的坑。尤其是竞赛和题库环境出题人非常喜欢塞N1、NU、U-D1这类边界数据专门治各种“看起来没问题”的公式。3.3 我的选型建议先看数据范围再决定我个人做题的习惯是先看数据范围再决定方法。如果N很小比如PTA这道题的常见限制N不超过100我会无脑用模拟法因为代码量差不多而且逻辑直观到不可能出错。如果哪天遇到N高达10^9甚至更大的变体题模拟会超时这时候再用3.1的严格公式。换句话说不是公式不能用而是你要知道它为什么对、在什么条件下对。对于学习阶段我更建议把模拟法作为默认答案把公式法作为“性能优化手段”来储备而不是一开始就奔着公式去。这样你能少踩很多莫名其妙的WA。4. 从WA到AC这道题最容易出现的三个翻车点4.1 翻车点一把“先休息后爬行”当成初始状态我第一次WA的原因就是这个。当时我写了一个循环循环体开头直接pos - d然后再pos u并且把时间算成两分钟一轮。跑样例N3、U2、D1时程序输出的结果是6而正确答案是3。后面的确手算了一下才反应过来蠕虫一开始是在井底的第1分钟根本不该下滑应该先往上爬。下滑是“爬完之后休息”的伴随结果不是蠕虫的初始状态。这类错误很隐蔽因为代码结构看起来特别对称先滑再爬先爬再滑都是两分钟。可一旦起始状态错了整条时间轴就整体后移结果当然全错。所以以后凡是做“每做X然后做Y”的循环模拟第一件事就是在纸上写下初始状态和第一个动作不要凭感觉写循环体。4.2 翻车点二判断出井的时机被放到了下滑之后第二种常见错误是把判断放在一个“完整两分钟周期”的末尾比如这样while (pos n) { pos u; time; pos - d; time; if (pos n) { break; } }表面看起来也是在检查是否出井但检查点已经晚了。以N3、U2、D1为例第一次循环pos先变成2再变成1判断13不成立第二次循环pos先变成3再变成2判断23还不成立第三次循环pos先变成4再变成3判断33成立了输出6。明明是第3分钟就能出去代码非要拖到第6分钟才“发现”。这种错误特别容易出现在“把爬行和下滑合并成一个不可分割的动作”的代码里。解决办法只有一个爬行这个动作结束后、下滑这个动作开始前必须插入一次出井判断。这也是我在2.1那版代码里把break放在两次time之间的原因。4.3 翻车点三用浮点或整除处理公式时的精度问题用数学公式时如果写成int k (n - d) / (u - d)Java的整数除法会直接丢掉小数部分。这个坑在上面3.2已经展示过。还有一种更隐蔽的写法是int ans (int) ((n - u) / (double) (u - d)) * 2 1;这里括号位置不同结果也可能不同。一旦出现“有些样例过了、有些样例WA”的诡异情况大概率就是公式里有除法取整或浮点转整型的问题。我在实际刷题时遇到这种“玄学WA”第一反应不是重新读题而是把所有除法都改成模拟循环先确认思路本身对不对再回头优化。这是性价比很高的排查顺序。4.4 给你一个能直接抄走的调试模板最后分享一个调试方法它帮我解决了不止一道模拟题。写代码时顺手在每个动作结束的地方打印一行状态然后拿最小样例去跑观察它是否符合手推的过程。int pos 0; int time 0; while (pos n) { time; pos u; System.out.println(time time 爬行后 pos pos); if (pos n) { break; } time; pos - d; System.out.println(time time 休息后 pos pos); } System.out.println(answer time);对N3、U2、D1来说输出应该是time1 爬行后 pos2 time2 休息后 pos1 time3 爬行后 pos3 answer3如果你的输出序列中间多了“time4休息后pos2”之类的尾巴说明你在爬行后漏掉了break或者把判断写到了循环末尾。这一招比盯着代码干想快得多。当你确认逻辑没问题后再把这行打印删掉或注释掉正常提交即可。5. 从这道题往外走状态机思维和面向对象的合理封装5.1 把蠕虫看成一台状态机这道题往上抽象一层本质是一个状态机蠕虫只有两个状态爬行态和休息态。状态迁移规则是“爬行一分钟进入休息态休息一分钟回到爬行态”每个状态结束之后都要检查“是否到达终点”只不过休息态结束后不可能到达终点所以可以跳过。这样的抽象对后续做更复杂的模拟题非常有用。一般模拟题的通用骨架可以写成这样初始化状态和位置。在循环里执行当前状态对应的动作。更新时间、更新位置。检查结束条件如果满足就退出。切换到下一个状态继续循环。这套骨架几乎适用于所有“按时间片推进”的题目从PTA天梯赛里的电梯、机器人到蓝桥杯里的小游戏模拟都能套。你只要记住“事件发生后立刻检查”这一条很多看起来绕的题都会变得清晰。5.2 用Java面向对象的方式重写一遍虽然PTA提交时需要的是单个Main类但如果你在本地练习我推荐试着把蠕虫封装成一个Worm类。这不只是为了好看它能把“位置、时间、动作规则”这些状态集中管理以后想扩展不同属性、多条蠕虫都很方便。class Worm { private int pos; private int wellDepth; private int upSpeed; private int downSpeed; private int minutes; public Worm(int wellDepth, int upSpeed, int downSpeed) { this.wellDepth wellDepth; this.upSpeed upSpeed; this.downSpeed downSpeed; this.pos 0; this.minutes 0; } public boolean isOut() { return pos wellDepth; } public void moveOneAction(boolean isRest) { if (isRest) { pos - downSpeed; } else { pos upSpeed; } minutes; } public int getMinutes() { return minutes; } }主程序就变得很干净Worm worm new Worm(n, u, d); boolean rest false; while (!worm.isOut()) { worm.moveOneAction(rest); rest !rest; } System.out.println(worm.getMinutes());这里用boolean变量rest来标记当前一分钟是爬还是休息。初始rest为false表示第一分钟爬行执行完一个动作后取反下一分钟就变成休息以此类推。这样写的好处是如果题目改成“每爬两分钟休息一分钟”或者“下滑速度会变化”你只需要改moveOneAction里的动作细节循环结构和判断逻辑基本不用动。这也是我建议Java初学者多做这件事的原因PTA基础题看似只需要一个Main类但“把对象的状态和动作封装起来”本身就是面向对象编程的初级练习。等你到了Java面试阶段看到“如何设计一个电梯类”“如何设计一个订单状态机”这类题就会发现当年在蠕虫身上练过的这套状态封装完全能复用。5.3 同类题怎么举一反三“爬动的蠕虫”从题目家族来看属于“蜗牛爬井”类问题。同族的变体通常有这么几种每爬U分钟休息S分钟休息期间下滑D寸。上升速度有衰减比如每爬一次减少1寸。井壁有一段光滑区爬到某个高度后不再下滑。有多个蠕虫同时从不同深度开始爬问谁先出来。这些题的共同套路是先把一只蠕虫的完整动作序列写出来再在合适的位置插入条件判断。你不需要去背每种题的特殊公式只要你手里有一套“动作驱动”的循环模板改起来就是换动作、换数据的事。这也是为什么我说这道题适合当作模拟题的第一课——它没有复杂的数据结构没有高级算法有的只是一个干净利落的循环结构和精准的退出条件判断。6. 做完这道题我留下的几个习惯回头看这道题它给我最大的帮助不是让我学会了Java里的while循环而是纠正了我刷题时的一个毛病总想用一行公式解决所有问题却不愿意老老实实把过程拆开。现在我做模拟题之前会先在草稿纸上把第一轮动作完整写出来确认清楚每一步的起点、终点、动作顺序再开始敲代码。这比写完代码后反复调试省的时间多得多。另外一个习惯是提交前跑边界数据。以这题为例我一定会跑这几组N1、U任意验证它能否直接出去NU验证爬到刚好到井口的那一分钟能不能正确停住U-D1验证长时间净上升很慢时逻辑是否仍然正确。多数WA都是撞在这些边界上而不在正常样例上。做完这些验证再去PTA提交通过率会高很多。这道题本身很简单但它背后“事件发生后再判断”的思路会一直跟着你走到更复杂的题目里去。如果你现在刚好在刷PTA的Java基础题单不妨把这道题再手写一遍试着不用看任何参考代码自己在本地跑通所有边界数据。写对的那一刻你收获的不只是这道题的分数还有一套能用在很多地方的状态推进思维。