资讯详情

资讯详情

出现 537 次的唯一 Hard:难在一根指针上

高频榜前十名里只有一道 HardK 个一组翻转链表出现 537 次排第 5。但这题有个很迷惑人的特点——它的思路一句话就能说完。把链表每 k 个切成一组组内反转再把各组接起来。讲完。你在面试里花 15 秒就能把这个方案讲给面试官听然后开始写然后大概率写崩。我拿三种看起来很合理的写法做了穷举搜索想找出每种写法最早在哪个输入上出错。结果挺有意思其中一种错误写法节点一个没丢、一个没多、长度完全正确只有逐位比对序列才能发现它是错的。这题考的根本不是你想不想得到方案是你敢不敢在动了第一根指针之后还记得其余五根在哪。一、先把正解写出来publicListNodereverseKGroup(ListNodehead,intk){ListNodedummynewListNode(0);dummy.nexthead;ListNodeprevGroupdummy;while(prevGroup.next!null){ListNodekthprevGroup;for(inti0;ikkth!null;i)kthkth.next;if(kthnull)break;// 不足 k 个保持原序ListNodegroupNextkth.next;// 本组的后继反转的终止哨兵ListNodeprevgroupNext,curprevGroup.next;// ★ prev 初值是 groupNext不是 nullwhile(cur!groupNext){ListNodenxtcur.next;cur.nextprev;prevcur;curnxt;}ListNodeoldFirstprevGroup.next;// 原组头反转后变成组尾prevGroup.nextkth;// 前驱接上新组头prevGroupoldFirst;// ★ 游标移到组尾不是 kth}returndummy.next;}二十来行里面标了两个★。这两个星号加上break那一行就是这题的全部难点。二、逐帧看一遍一组要经过六次指针交接用[1,2,3,4,5]、k2走一遍把每一帧的prev、cur、nxt和链表当时的真实形态都记下来看第 3 帧有个细节值得单独说节点 2 从 dummy 出发已经走不到了它只被cur指着。这就是为什么代码里必须先ListNode nxt cur.next;再改cur.next prev;。一旦先改了cur.next后半段链表就只能靠你事先存下的那个引用找回来了——顺序写反链表当场断掉而且断得很安静。再看最后两帧。第二组接回后链表是2→1→4→3→5游标停在节点 3原组头现组尾。下一轮从它出发走 2 步3→5→null撞到 null于是break剩下的5保持原序。不足 k 个保持原序这条规则就是靠这个break实现的。它看着最不起眼但漏掉它的代码在 LeetCode 上会直接判错——见下一节 bug3。三、三种典型错误和它们各自的最小反例我把三个最容易写错的地方各做成一个 bug 版本然后让程序在len ≤ 8的全排列空间里穷举搜索找每种错误最早暴露的输入。bug错误写法最小反例错误输出正确输出bug1组内反转的prev初值写成null[1,2,3]k2[2, 1][2, 1, 3]bug2游标更新写成prevGroup kth[1,2,3]k2[2, 3, 1][2, 1, 3]bug3不足 k 个的尾巴也硬反转[1,2,3,4,5]k3[3, 2, 1, 5, 4][3, 2, 1, 4, 5]三个 bug 的失败形态完全不同bug1是断链。prev初值是null第一组反转完原组头节点 1 指向了 null节点 3 从此失联。输出只剩两个节点。这个错误最良心因为它会体现在长度上。bug2最阴。游标本该移到组尾也就是原组头oldFirst写成kth就移到了组头。下一轮从这个位置再走 k 步取到的区间和上一组重叠了。结果[1,2,3]变成[2,3,1]——三个节点全在一个不多一个不少只是顺序不对。bug3是语义错。末组只有两个节点硬反转成[5,4]。节点也全在。于是有个很不舒服的结论bug2 和 bug3 都能通过数一下长度对不对和元素齐不齐这两种自查。一个节点没丢、没重复值的多重集完全守恒。你在面试里手写完快速扫一眼嗯1 2 3 4 5 都在就放过去了。唯一能抓住它们的是逐位比对序列。四、所以链表题应该怎么自查我自己写验证程序时的做法是拿一个笨到不可能错的参考实现做基准先把值全部取进数组按题意在数组里重排够 k 个就反转这一段不够就原样留着再照着新数组重建一条链表。这个参考实现的时间空间都很差但它的正确性一眼可见。然后给它配三重检查按代价从低到高排1) 节点数守恒 —— 从 head 走步数超过原长即判定成环步数不足即判定丢节点 2) 值多重集一致 —— 排序后比对抓重复节点和丢节点 3) 逐位序列比对 —— 与参考实现的结果逐个比第 1 重检查必须写成步数上限而不是走到 null 为止。因为断链重连的另一端失败形态是成环while (cur ! null)会永远走不完。我在写 bug2 的搜索时专门加了步数上限保护就是防这个。穷举 随机的结果穷举组合300长度 0..9 × k 1..10 × 三种数据形态迭代/递归各跑一遍不一致0 随机用例50000长度 0..59k 1..12新增不一致0面试时你没法跑测试但这三重检查对应的自查动作是可以手做的而且很便宜拿[1,2,3,4,5]k2 心算一遍答案是2→1→4→3→5。这是 LeetCode 官方第一个用例也是能同时暴露三个 bug 的最小输入。拿 k1 自检。每组一个节点反转是恒等操作答案必须等于原链表。如果你的代码在 k1 时结果变了说明组的边界算错了。拿 k 大于长度自检。一个节点都不该动。这三个用例覆盖掉了这题所有的失败分支成本是三十秒。五、三种写法和一个我亲自中的 off-by-one迭代版是面试首选O(1) 额外空间。递归版更短把接回下一组这件事交给返回值publicListNodereverseKGroupRec(ListNodehead,intk){ListNodekthhead;for(inti1;ikkth!null;i)kthkth.next;// 注意是 i 1if(kthnull)returnhead;ListNodegroupNextkth.next,prevgroupNext,curhead;while(cur!groupNext){ListNodenxcur.next;cur.nextprev;prevcur;curnx;}head.nextreverseKGroupRec(groupNext,k);// 原组头现组尾接下一组的结果returnkth;// 新组头}这里有个我自己真的中过的坑。迭代版里找第 k 个节点是kth prevGroup然后走k步因为prevGroup在组外面递归版里起点是head它已经是组内第 1 个了只能走k−1步。我写递归版时直接照抄了for (i 0; i k; i)结果kth落到了下一组的第一个节点上。穷举测试跑第一遍就报了一片len2, k1期望[1,2]实际[2,1]——k1 时它把整条链表反转了。两版混着抄是最容易中的毒。面试官让你再给一个递归写法考的往往不是你会不会递归是你有没有注意到起点变了、步数也得跟着变。递归版另一个追问点是空间深度是 n/k。n10⁵、k2 时递归 5 万层Java 默认栈是可能 StackOverflow 的。面试官问能不能不用递归就是在等这句。值回填法——把值取进数组、重排、写回原节点——在 LeetCode 上能 AC。但它经不起追问如果节点挂着大对象、或者题目要求真正移动节点这个写法直接出局。它能过只是因为这道题恰好只关心值的顺序。用之前先想清楚这一点别默认它是最优解。六、这题的追问清单追问想确认什么一句话答案prev为什么初始化成groupNext是否理解组间衔接原组头反转后要指向下一组开头指 null 就断链游标为什么移到oldFirst是否理解组尾变了反转后原组头成了组尾下一组要接在它后面不足 k 个怎么办有没有读清题保持原序靠走 k 步撞 null 就 break实现递归版空间多少会不会算复杂度O(n/k) 栈深度n 大时有爆栈风险k1 时结果应该是什么有没有自检习惯必须等于原链表不等就是组边界算错递归版怎么找第 k 个节点会不会照抄出错起点是 head只能走 k−1 步值回填能过吗知不知道解法边界能过 LeetCode但没真正移动节点经不起追问如果要求每 2k 组只翻前 k 个能不能泛化加一个组序号奇偶判断即可骨架不变金句链表题的难从来不在想到怎么做而在改了指针之后你还记得有几个节点已经不属于原来的位置了。下一篇想写排名第 3 的反转链表出现 758 次。它是这十道里唯一标着 Easy 的七行代码看起来最没得可讲。但它是前十名里唯一一道递归版和迭代版复杂度不同的题——而且面试官真正想问的那句是递归返回的时候栈上每一层的head分别指向谁。本文代码与配图由作者用华为云码道CodeArts 代码智能体辅助完成三种写法与三个错误版本均经穷举与随机对撞验证。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →