news 2026/9/16 2:58:37

链表OJ进阶:快慢指针与区间反转等高频套路全拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
链表OJ进阶:快慢指针与区间反转等高频套路全拆解

上一篇把链表最基础的那批 OJ 题过了一遍,反转整个链表、倒数第 K 个节点、合并两个有序链表这些,属于“热身级”。这一篇要往上走一层,聊真正在面试和竞赛里拉开差距的进阶题:快慢指针系列、区间反转、K 个一组翻转、带随机指针的链表复制、链表归并排序、重排链表。这些题我在各个 OJ 平台(杭电、牛客、华为 OD 的在线题库里都反复遇到过)刷了很多遍,也踩过不少坑,这篇把每道题的核心思路、完整代码和容易翻车的细节一起拆开讲清楚。

很多刚刷链表题的朋友有个共同困惑:光会遍历、插入、删除,真到综合题就卡壳,明明看题解能看懂,自己写就错。这其实是“套路的储备量”不够。链表在 OJ 里的常考套路就那么几板斧——快慢指针、哑节点、头插法、递归反转、归并思想。只要这几种手法练熟,大部分中等难度的链表题都是“模板组合”而已。这篇就是围绕这些高频套路展开,把它们的来龙去脉讲透。

1. 快慢指针:四道链表中级题,一个套路全部拿下

先聊快慢指针。数组有下标,随手arr[i]就能跳到任意位置,链表不行,只能从头一个 next 一个 next 地走。但很多问题并不需要知道确切的“第几个位置”,只需要确定“某个特殊位置”——中间点、是否有环、环的入口、两个链表是否相交。这时候让两个指针以不同速度在链表上跑,往往是最高效的方案。

快慢指针的“快”和“慢”可以设定得很灵活。最常见的搭配是慢指针一次走一步,快指针一次走两步。为什么选 2 而不是 3 或 4?因为步长 2 的数学性质最干净:当快指针走完整个链表时,慢指针正好走到一半(整数位置),不需要处理余数。步长为 3 虽然在某些场景也能用,但边界判断会变得冗长,而且容易踩到奇偶性的坑,实际编码中很少这么干。

1.1 找链表中间节点:从两次遍历到一次遍历

题目描述很简单:给定一个单链表的头节点head,返回链表的中间节点。如果链表节点数为偶数,则返回第二个中间节点。比如链表1 -> 2 -> 3 -> 4 -> 5,结果是3;链表1 -> 2 -> 3 -> 4 -> 5 -> 6,结果是4

第一反应是两次遍历:第一次数出总长度n,第二次走n / 2步。这个能做,OJ 也能 AC,但问题是“两次遍历”在后续复杂题目里会成为性能瓶颈的影子。比如后面要说的重排链表、链表归并排序,需要频繁找中点,每次都两遍扫描,整体效率就不行了。

快慢指针的做法是:慢指针slowhead出发,快指针fast也从head出发,slow每步走一个节点,fast每步走两个节点。当fast走到链表末尾时,slow刚好停在中间。直接看代码:

struct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} ListNode(int x, ListNode *next) : val(x), next(next) {} }; ListNode* middleNode(ListNode* head) { ListNode *slow = head, *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } return slow; }

推导一下为什么偶数个节点时会返回第二个中间节点。假设链表有 6 个节点,下标从 1 到 6。初始slow = 1, fast = 1。第一轮后slow = 2, fast = 3;第二轮后slow = 3, fast = 5;第三轮时fast不为空但fast->next为空(第 5 个节点后面只有一个第 6 个节点,但fast走到第 5 个时fast->next指向第 6 个节点,不为空,所以还会再走一次第三轮),因此第三轮后slow = 4, fast = 7(第 6 个节点的 next 是 nullptr),循环结束,返回4。正好是第二个中间节点。

注意while (fast && fast->next)这个条件的顺序不能写反。如果先判断fast->next再判断fast,当fast本身就是空指针时,会直接出现空指针解引用崩溃。这在 OJ 里是常见的运行时错误,后面第 4 章我还会专门展开。

1.2 判断链表是否有环:相遇背后的数学原理

给你一个链表的头节点head,判断链表中是否有环。力扣 141 题,几乎每个 OJ 平台上都有这道题的原型。

我见过不少人的“暴力”做法:用哈希表把每个访问过的节点地址存起来,如果某个节点地址重复出现,说明有环。这是对的,空间复杂度 O(n),很多考试也能过。但快慢指针的解法更漂亮:slow每次走一步,fast每次走两步,如果链表无环,fast会先遇到空指针;如果有环,两个指针一定会在环内相遇。

关键问题是:为什么它们一定相遇?这背后是相对速度的原理。想象两个人在环形跑道上跑步,fast的速度是slow的两倍,也就是每单位时间fastslow多跑一个节点的距离。只要有环,slow一旦进入环,就可以看成fast在后面追slow,每次追近“一步”。因为追及速度是 1,不存在“跳过去”的情况,所以必然会追上。这也解释了为什么步长选 2 很安全:相对位移是 1,遍历是连续的,不可能错过。

bool hasCycle(ListNode *head) { ListNode *slow = head, *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) { return true; } } return false; }

这里有个细节容易忽略:判断相遇的if (slow == fast)放在两个指针都移动之后,而不是循环开头。因为初始状态下slowfast都指向head,如果放到循环开头,一进来就会判定相等,整个判断就废了。

补充一个延伸思考:如果快指针一次走三步、慢指针一次走一步,还能保证相遇吗?理论上在环内也能相遇(追及距离变成了动态变化),但分析会复杂很多,而且当环的长度与快指针步长存在某种倍数关系时,可能出现多次“擦肩而过”的情况,需要更多轮次才能追上。所以工程实践里普遍选用步长 2,够用且证明简单。

1.3 环形链表 II:入口定位的三段式推导

上一题只要求判断有没有环,这一题更进一步:如果有环,返回环的入口节点;如果没有环,返回nullptr。力扣 142 题,华为 OD 机试中这道题的变体也经常出现。

思路分为三步:

  1. 先用快慢指针判断是否有环,并记录相遇点。
  2. 将其中一个指针重新指向头节点,另一个留在相遇点。
  3. 两个指针都改为每次走一步,再次相遇的位置就是环的入口。

这个结论很反直觉,我当初学的时候也是画了好几张图才彻底想通。设链表头到环入口的距离为a,环入口到快慢指针第一次相遇点的距离为b,相遇点继续走回环入口的距离为c。那么环的周长是b + c

slowfast第一次相遇时,slow走了a + b步,fast走了a + b + n * (b + c)步,其中nfast已经绕环走的圈数。因为fast的速度是slow的两倍,所以:

2 * (a + b) = a + b + n * (b + c) => a + b = n * (b + c) => a = n * (b + c) - b

n = 1时,a = c。也就是说,从头节点到入口的距离,等于从相遇点继续走到入口的距离。即使n > 1,结论依然成立:一个指针从头开始,另一个从相遇点开始,都走一步,最终会在环入口相遇。

变量含义
a头节点到环入口的距离
b环入口到第一次相遇点的距离
c第一次相遇点继续走到环入口的距离
b + c环的周长
nfast 在相遇前绕环的圈数
ListNode *detectCycle(ListNode *head) { ListNode *slow = head, *fast = head; bool hasCycle = false; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) { hasCycle = true; break; } } if (!hasCycle) return nullptr; slow = head; while (slow != fast) { slow = slow->next; fast = fast->next; } return slow; }

一个很容易踩的坑是:找到相遇点后,有人直接把slow重新指向head,然后让fast每次走两步、slow每次走一步,想用“再次追及”来找入口。这不对,会让问题退化回判断有没有环,永远找不到入口。正确的做法是:两个指针都变成每次走一步,让它们在数学上“精准相遇”于入口。

1.4 两个链表的第一个公共节点:双指针拼接的巧思

输入两个无环单链表,找出它们的第一个公共节点。没有公共节点则返回nullptr。这道题在很多 OJ 平台上有不同版本的描述,本质都一样。

最直接的办法是哈希表:先把链表 A 的所有节点地址放进集合,再遍历链表 B,第一个在集合中出现的节点就是交点。时间 O(n+m),空间 O(n)。但双指针法可以做到空间 O(1)。

双指针的核心思想是“拼接链表”:指针pA从链表 A 的头部出发,指针pB从链表 B 的头部出发;当pA走完链表 A 后,跳到链表 B 的头部继续走;当pB走完链表 B 后,跳到链表 A 的头部继续走。最终两者要么在交点相遇,要么同时走到nullptr

为什么它能相遇?因为两个指针最终走过的总路程相同:都是lenA + lenB。如果两个链表有交点,那么从交点开始的后半段是共享的,它们必然会在交点处第一次“同步”。这个思路我每次讲都会被问到“要是没有交点呢?”——没有交点时,它们会在同时到达nullptr,因为两个指针走了相同的总长度后,都指向空,循环自然结束。

ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { if (!headA || !headB) return nullptr; ListNode *pA = headA, *pB = headB; while (pA != pB) { pA = pA ? pA->next : headB; pB = pB ? pB->next : headA; } return pA; }

这里的pA = pA ? pA->next : headB是关键。当pA为空时,跳到对方链表的头节点,而不是直接返回空。很多初学者会写成if (!pA) return nullptr,那就把题目理解窄了,只处理了没有交点的情况。

2. 从基础反转升级:区间反转与 K 个一组翻转的拆解

反转链表是很多人的“舒适区”,三指针迭代或者递归都能写。但一旦从“反转整个链表”变成“反转某个区间”再到“K 个一组翻转”,就有大量的人开始出 bug。原因在于:整体反转只需要动一次头尾,区间反转需要同时维护四个边界指针(前驱、反转段头、反转段尾、后继),而分组翻转还要处理好“断链”和“续链”的时机。

这一章把两类反转进阶题彻底讲透。

2.1 反转链表 II:哑节点与头插法的配合

题目要求:给定单链表头节点head和两个整数leftright,反转从位置left到位置right的链表节点,返回反转后的链表。力扣 92 题的原型,很多 OJ 上叫“反转链表 II”或“指定区间反转”。

最朴素的想法是:先找到left位置的节点,然后从leftright执行一遍标准三指针反转,再接回原来的链表。这个思路没问题,但实现时有个很烦的边角情况:当left = 1时,反转从头节点开始,没有“前驱节点”,必须做特殊判断,否则代码会到处缝缝补补。

更好的方案是引入哑节点dummydummy是一个虚拟头节点,dummy->next = head。有了它,left = 1的情况就和普通情况统一了——前驱节点就是dummy

算法步骤:

  1. 创建dummy节点,dummy->next = head
  2. 找到第left个节点的前驱节点pre
  3. left + 1right,逐个将节点“头插”到pre的后面。
  4. 返回dummy->next

第 3 步的头插法是区间反转的核心。假设当前cur指向left位置的节点,next指向cur->next。每轮操作是:

cur->next = next->next; next->next = pre->next; pre->next = next; next = cur->next;

这段操作的意思是:把当前要反转的节点摘出来,塞到前驱节点后面。循环执行right - left次,区间内的节点就全部反向。

完整代码:

ListNode* reverseBetween(ListNode* head, int left, int right) { ListNode* dummy = new ListNode(0); dummy->next = head; ListNode* pre = dummy; for (int i = 0; i < left - 1; i++) { pre = pre->next; } ListNode* cur = pre->next; ListNode* nextNode = nullptr; for (int i = 0; i < right - left; i++) { nextNode = cur->next; cur->next = nextNode->next; nextNode->next = pre->next; pre->next = nextNode; } ListNode* newHead = dummy->next; delete dummy; return newHead; }

我记得第一次写这段代码时,在nextNode->next = pre->next这一行纠结了很久,为什么不是nextNode->next = cur?关键在于每次循环时pre->next都指向当前区间的“最新头节点”,把nextNode插到它前面,才能实现逐步反转的效果。如果你把它接到cur上,那么第一次循环后就会丢掉已经反转好的部分,输出结果会变成中间断开的乱序链表。

2.2 K 个一组翻转链表:分组丈量 + 局部反转 + 断链重连

这是链表反转问题里的“天花板”之一。题目描述:给你一个链表,每 K 个节点一组进行翻转,不足 K 个的保持原样。力扣 25 题,华为 OD 在线题库里出现过类似场景题。

组合拳打法分三步:

  1. 从当前段的起点开始,往后数 K 个节点,找到段尾。
  2. 如果不足 K 个,说明到了末尾,保持原样,直接结束。
  3. 如果够 K 个,反转这一段,然后接回原来的链表,继续下一段。

难点在第 3 步的“接回”。反转完一段之后,段的前驱要指向反转后的新头,段的尾部要接到下一段的头部。写代码时如果只盯着“反转这一段看”,很容易漏掉前后衔接。

完整实现:

ListNode* reverseKGroup(ListNode* head, int k) { ListNode* dummy = new ListNode(0); dummy->next = head; ListNode* pre = dummy; ListNode* cur = head; while (cur) { // 1. 检查剩余节点是否还有 k 个 ListNode* tail = cur; int count = 0; while (count < k && tail) { tail = tail->next; count++; } if (count < k) { break; // 剩余节点不足 k 个,保持原样 } // 2. 此时 tail 指向第 k+1 个节点,反转 [cur, tail) 这一段 ListNode* newTail = cur; ListNode* prev = nullptr; ListNode* curr = cur; for (int i = 0; i < k; i++) { ListNode* nextNode = curr->next; curr->next = prev; prev = curr; curr = nextNode; } // 3. 接回链表 pre->next = prev; // 反转后的新头接到前驱后面 newTail->next = curr; // 反转后的段尾接到下一段的头部 pre = newTail; // 更新前驱 cur = curr; // 更新当前节点 } ListNode* newHead = dummy->next; delete dummy; return newHead; }

这代码里最巧妙的地方是用tail先“丈量”剩余的节点数,而不是像区间反转那样边反转边判断。因为tail最终指向的是第k+1个节点,只有在确认当前段确实够k个节点之后才执行反转操作,这样就不会出现“反转到一半发现不够了”的尴尬情况。

初次尝试时我犯过一个低级错误:反转结束后直接让pre->next = prev,但忘记把段尾newTail->next接回curr,导致本地测试时打印链表,总在分段处断掉。这种问题在 OJ 上表现为输出结果缺失后半段,排查时需要把链表打印出来,对照每个段的头尾指针逐一检查。

3. 复杂链表实战:随机指针复制、归并排序与重排

到了这一章,单纯“会遍历”“会反转”已经不够看了。三道题分别考察三个方向的进阶能力:复杂结构建模(随机指针)、高级算法在链表上的适配(归并排序)、多技巧组合运用(重排链表)。它们共同的特点是:如果没有提前积累解法套路,现场想很容易卡壳。

3.1 复制带随机指针的链表:空间换时间与原地复制两种路线

题目描述:给你一个长度为 n 的链表,每个节点除了next指针外,还有一个random指针,可能指向链表中的任意一个节点,也可能指向null。要求深拷贝这个链表。力扣 138 题,被各大 OJ 收录为经典。

所谓“深拷贝”,就是不能只复制next关系,还要让新链表的每个节点的random都指向新链表中对应的节点,而不是旧链表的节点。直接遍历原链表复制普通节点很容易,难的是random的映射关系——旧节点和新节点是一一对应的,但原链表里random指向哪个旧节点,新链表里就要相应指向哪个新节点。

解法一:哈希表映射。遍历原链表,为每个节点创建新节点,并建立“原节点 -> 新节点”的映射;第二次遍历时,通过映射关系,把新节点的nextrandom都设置好。时间 O(n),空间 O(n)。

class Solution { public: Node* copyRandomList(Node* head) { if (!head) return nullptr; unordered_map<Node*, Node*> mp; Node* cur = head; while (cur) { mp[cur] = new Node(cur->val); cur = cur->next; } cur = head; while (cur) { mp[cur]->next = mp[cur->next]; mp[cur]->random = mp[cur->random]; cur = cur->next; } return mp[head]; } };

解法二:原地复制拆分,空间 O(1)。这个方法非常巧妙,我头一次看到时觉得像是“链表上的魔术”。

  1. 第一次遍历原链表,对每个节点在原节点后面插入一个复制的节点,next指向原节点的next
  2. 第二次遍历,处理random:新节点的random就是原节点random->next(因为每个原节点的后面紧跟着它的复制节点)。
  3. 第三次遍历,把链表拆开:奇数位是原链表,偶数位是复制链表。
Node* copyRandomList(Node* head) { if (!head) return nullptr; // 第一步:复制节点接到原节点后面 Node* cur = head; while (cur) { Node* copy = new Node(cur->val); copy->next = cur->next; cur->next = copy; cur = copy->next; } // 第二步:设置 random cur = head; while (cur) { if (cur->random) { cur->next->random = cur->random->next; } cur = cur->next->next; } // 第三步:拆分链表 cur = head; Node* newHead = head->next; while (cur) { Node* copy = cur->next; cur->next = copy->next; if (copy->next) { copy->next = copy->next->next; } cur = cur->next; } return newHead; }

第三步拆分时最容易出错的地方是:复制节点的next要指向“复制链表中下一个复制节点”,也就是copy->next->next。如果直接写成copy->next = cur->next,拆分结果会乱套,新链表的尾部接不上,甚至可能指向已经被拆走的原链表节点。

解法时间复杂度空间复杂度适用场景
哈希表映射O(n)O(n)好理解、代码简单,优先选择
原地复制拆分O(n)O(1)对空间有严格要求的面试场合

哈希表法适合“求稳”,写起来不容易出错。原地复制适合表现“你对链表结构理解足够深”,但忽略一个小步骤就可能全盘崩掉。如果是 OJ 刷题,建议先把哈希表法完全拿下,再练熟原地法。

3.2 链表排序的最佳选择:归并排序在单链表上的落地

给一个单链表,要求在 O(n log n) 时间复杂度和 O(1) 空间复杂度内排序。数组里能做到这个复杂度的是堆排序、快速排序和归并排序。但链表上堆排序要建堆,快速排序需要随机访问基准值,都不合适。归并排序成了链表排序最自然的解法。

自顶向下的归并排序在链表上实现思路清晰:

  1. 找到链表中点,把链表拆成左右两半。
  2. 分别对左右两半递归排序。
  3. 合并两个有序链表。

找中点复用 1.1 节的快慢指针。合并两个有序链表是链表基础题里最常见的操作。两者一组合,代码就出来了:

ListNode* sortList(ListNode* head) { if (!head || !head->next) return head; // 1. 找中点,断开链表 ListNode* slow = head; ListNode* fast = head->next; // 注意:这里 fast 先走一步 while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } ListNode* mid = slow->next; slow->next = nullptr; // 2. 递归排序 ListNode* left = sortList(head); ListNode* right = sortList(mid); // 3. 合并两个有序链表 ListNode* dummy = new ListNode(0); ListNode* cur = dummy; while (left && right) { if (left->val <= right->val) { cur->next = left; left = left->next; } else { cur->next = right; right = right->next; } cur = cur->next; } cur->next = left ? left : right; ListNode* newHead = dummy->next; delete dummy; return newHead; }

有个细节很多人第一次写都会错:找中点时,fast的初始值是head->next而不是head。原因和 1.1 节的题目要求有关——那题要求偶数节点时返回第二个中间节点,而排序拆链表时,我们希望左半段和右半段尽量均匀,所以用fast = head->next可以让slow停在前半段的末尾,从而顺利拆链。

自顶向下的写法使用了递归,空间复杂度是 O(log n)(递归栈深度)。面试时写这个版本完全够用,代码简洁。如果 OJ 给出的额外空间限制非常严苛,要求 O(1) 空间,那就得用自底向上的归并排序,把链表拆成一个个长度为 1 的有序块,再两两合并,整个过程靠循环完成。那个实现代码量大不少,日常刷题可以等基础版本写熟之后再挑战。

3.3 重排链表:找中点、反转后半段、交替穿插三件套

题目描述:给定单链表L0 -> L1 -> ... -> Ln-1 -> Ln,重排为L0 -> Ln -> L1 -> Ln-1 -> L2 -> Ln-2 -> ...。力扣 143 题,看起来挺花哨,实际就是把前半段按顺序拿一个,后半段倒序拿一个,交替拼接。

标准解法就是 1.1 节和 2 节技巧的组合:

  1. 用快慢指针找到链表中点,把链表拆成前后两半。
  2. 反转后半段链表。
  3. 将前半段和反转后的后半段交替拼接。

第三步交替拼接时,我用一个辅助技巧:声明两个指针p1p2分别指向两段头部,然后循环执行:

p1_next = p1->next; p1->next = p2; p1 = p1_next; p2_next = p2->next; p2->next = p1; p2 = p2_next;

核心是每次先把原本的下一个节点保存下来,再修改指针指向,否则会丢失后续节点,链表直接断裂。

完整代码如下:

void reorderList(ListNode* head) { if (!head || !head->next || !head->next->next) return; // 1. 找中点 ListNode* slow = head; ListNode* fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } // 2. 反转后半段 ListNode* second = slow->next; slow->next = nullptr; ListNode* prev = nullptr; while (second) { ListNode* nextNode = second->next; second->next = prev; prev = second; second = nextNode; } ListNode* reversedSecond = prev; // 3. 交替穿插 ListNode* first = head; ListNode* sec = reversedSecond; while (sec) { ListNode* firstNext = first->next; ListNode* secNext = sec->next; first->next = sec; sec->next = firstNext; first = firstNext; sec = secNext; } }

注意一个判断细节:当链表只有 0 个、1 个或 2 个节点时,重排结果等于原链表,不需要进入主逻辑。head->next->next判断很容易漏,漏了之后如果链表只有两个节点,s会指到第二个节点,反转后半段时能把链表处理对,但边界情况总是让人不放心。OJ 评测的数据集里永远有这种“极端小数据”,提前做好判空是链表题的保命技巧。

4. 在真实 OJ 评测环境里踩过的那些坑

代码逻辑对,不等于 OJ 能 AC。链表题的很多问题出在语言细节、内存管理和评测模式的理解上。尤其是华为 OD 机试、牛客这种以“核心代码模式”为主的平台,和杭电 OJ、郑州轻工业大学 OJ 这类“ACM 完整代码模式”的平台,提交习惯完全不同。这一章把我在各大 OJ 平台实测中踩过的坑集中整理一遍。

4.1 C/C++ 链表节点的内存管理:new 和 delete 的配合

如果你的链表节点用struct ListNode { int val; ListNode *next; }定义,并且你用的是 C++ 的new来创建节点,那内存释放时就要用delete。如果用了malloc创建节点,释放时就要用free。混用不是每次都报错,但一旦出错就是非常诡异的内存问题,轻则内存泄漏,重则 double free 导致程序崩溃。

在 ACM 模式下,链表题通常需要自己定义完整的数据结构,还要手写链表的构建和析构。释放链表时,很多人会这样写:

while (head) { ListNode* temp = head; head = head->next; delete temp; }

这个写法是正确的,顺序是先保存下一个节点,再释放当前节点。如果反过来,先把head释放掉再访问head->next,就是经典的 use-after-free 错误,OJ 不一定会立刻让程序崩溃,但可能输出随机值。

核心代码模式(比如华为 OD 的机试、牛客网的很多题)下,通常只要求你实现一个函数,内存由评测系统统一管理,这时不要让函数内部随意delete输入链表的节点,否则很容易把评测系统的数据给释放掉,导致结果异常。

4.2 判空顺序:为什么 while (fast && fast->next) 不能写成 while (fast->next && fast)

这是一个让我在大学第一次参加 OJ 比赛时吃过亏的点。以找链表中点为例:

// 正确写法 while (fast && fast->next) { ... } // 错误写法 while (fast->next && fast) { ... }

两种写法看起来只是换了判断顺序,但 C/C++ 的&&是短路运算,从左到右求值。如果fast已经为空,第二种写法先执行fast->next,就会解引用空指针,程序直接崩溃。

同样的道理适用于判断链表是否有环、找中间节点等所有需要快慢指针的题目。链表题里“判空放前面”是铁律,不管条件里有多少个指针,凡是可能为空的一律放在最左边。

4.3 核心代码模式和 ACM 模式的适配

华为 OD 的 OJ 在线题库、牛客网的大部分题目都采用“核心代码模式”,你只需要补全Solution类中指定的方法,输入输出由系统处理。比如:

class Solution { public: ListNode* middleNode(ListNode* head) { // 只需要写这里 } };

这种模式下,不要自己写main函数,不要自己读cin,也不要自己打印输出。把参数当成已经给好的数据,专注实现函数逻辑即可。

但如果你在校招笔试前习惯了牛客的核心代码模式,突然去杭电 OJ、POJ 这类 ACM 模式的平台做题,就会发现自己卡在了“输入输出不会写”上。ACM 模式下,需要你从标准输入读取数据,构建链表,调用函数,最后打印结果。

一个典型的 ACM 模式链表题模板:

#include <iostream> struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; int main() { // 读取节点个数 int n; std::cin >> n; // 构建链表 ListNode* head = nullptr; ListNode* tail = nullptr; for (int i = 0; i < n; i++) { int val; std::cin >> val; ListNode* node = new ListNode(val); if (!head) { head = node; tail = node; } else { tail->next = node; tail = node; } } // 调用核心逻辑 ListNode* result = middleNode(head); std::cout << result->val << std::endl; // 释放链表 ListNode* cur = head; while (cur) { ListNode* temp = cur; cur = cur->next; delete temp; } return 0; }

本地调试时强烈建议在 Visual Studio Code 或者 CLion 里搭一个这样的完整环境,手动构造几组测试数据,把函数测通之后再提交到 OJ。我在刷题初期吃过不少亏:自测一个样例通过了,信心满满提交,结果 Runtime Error。后来才意识到,自测样例太“友好”了,空链表、只有一个节点、全是相同值、链表特别长这些极端情况完全没有覆盖。

4.4 排查过程还原:一次 K 个一组翻转的悬空指针 Bug

说一个我真实经历过的 bug,排查过程对链表调试很有参考价值。

当时在牛客上刷一道 K 个一组翻转的题目,本地测试三个样例全部通过,一提交就报错,提示“段错误(Segmentation Fault)”。我反复检查逻辑,觉得没有问题,最后只能回到本地,把代码还原到最初的版本逐步调试。

出错代码的简化版:

while (cur) { ListNode* tail = cur; int count = 0; while (tail && count < k) { tail = tail->next; count++; } if (count < k) break; ListNode* prev = nullptr; for (int i = 0; i < k; i++) { ListNode* nextNode = cur->next; cur->next = prev; prev = cur; cur = nextNode; } pre->next = prev; newTail->next = cur; pre = newTail; }

表面上看起来没问题。我打印了每轮循环后的链表结构,发现当k = 2、链表有 4 个节点时,一切正常;但k = 3、链表有 4 个节点时,第二轮循环在cur节点上访问了空指针。

原因找到了:第一轮反转时,我把cur一路走到了第k+1个节点(即第二轮应该开始的位置)。但第二轮开始测量剩余节点时,tailcur出发,数了k = 3个节点后发现tail已经为空,进入了break,这没问题。问题在于:第二轮不反转了,但cur停在第 4 个节点(为空),我却在while (cur)的循环体里又执行了一次ListNode* tail = cur;开头的逻辑……不对,这个不会段错误。

真正的 bug 是:第一次反转后我没有检查pre->next的赋值是否让旧的cur丢失。仔细追踪变量后发现,newTail->next = cur中,cur此时指向第 4 个节点,而pre还是dummypre->next = prevdummy指向了反转后的新头。第二轮进入时,cur是第 4 个节点,后续while (cur)进入后,tail从第 4 个节点开始往后数 3 个,还没数完就碰到空指针,break退出,本来应该没问题。但段错误发生在newTail->next = curcur为空时,newTail是反转段的旧头,newTail->next = nullptr是合法的。问题竟然出在tail指针走到了空指针之后,我又马上进入下一次while (cur)判断:此时cur是 nullptr,循环结束,不进入,所以也不会段错误。

最后逐行排查发现,是我在一开始写的测量循环里少了条件里的一对括号:

while (tail && count < k) { // 正确 while (tail && (count < k)) { // 也可以 while (tail & count < k) { // 写错,位运算,导致 tail 条件失效

这种小错误编译器不会报错,逻辑上也“看似没问题”,但tail会在空指针上继续移动,最终段错误。这个坑给我最大的教训是:链表题的“丈量循环”里,指针安全永远是第一优先级,任何看似不起眼的运算符错误,都可能在你以为最不可能出错的地方给你致命一击。

5. 刷链表 OJ 的节奏建议与复盘方法

代码看得再多,不如自己上手跑一遍。我建议按下面的顺序刷链表题,每一类都练到能独立写出来再进入下一类。

第一阶段:基础操作与遍历(先建立肌肉记忆)

  • 反转链表(迭代 + 递归两种写法)
  • 合并两个有序链表
  • 删除链表的倒数第 N 个节点
  • 链表中倒数第 K 个节点

第二阶段:快慢指针专题(这一篇第 1 章覆盖)

  • 找中间节点
  • 判断链表是否有环
  • 找到环的入口
  • 找两个链表的交点

第三阶段:反转变种与复杂结构(这一篇第 2、3 章覆盖)

  • 反转链表 II
  • K 个一组翻转链表
  • 复制带随机指针的链表
  • 链表排序
  • 重排链表

第四阶段:综合实战

  • 在多个 OJ 平台交叉刷题,感受不同平台的输入输出差异
  • 控制每道题的完成时间,模拟笔试环境
  • 把错题集中记录在文档里,标注出错原因,定期复刷

整个过程中我有两个比较笨但非常有效的复盘方法:第一个是把链表的每一步变化都画在纸上,画着画着就能发现指针指错的位置;第二个是把每道题的 bug 记录成清单,比如“忘判空”、“反转后没接回原链表”、“快慢指针初始位置误差”,下次写完代码后对着清单逐项检查,能过滤掉一大半低级错误。这套方法对任何语言、任何 OJ 平台都通用,我也一直沿用到后来的工程开发里。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 2:57:44

Unity WebGL 平台下的 HybridCLR 热更实践与踩坑指南

刚把 Unity HybridCLR 这套组合从 WebGL 平台完整跑通&#xff0c;从立项到第一个线上包踩了不少坑&#xff0c;网上关于这个组合的完整记录确实少。项目本身是数字孪生和可视化大屏方向&#xff0c;需要在浏览器里直接跑&#xff0c;主包控制在十几兆&#xff0c;业务逻辑要能…

作者头像 李华
网站建设 2026/9/16 2:54:44

AttacKG:面向网络威胁情报的专用知识图谱构建模型

简介&#xff1a;本资源为网络安全知识图谱领域前沿论文《AttacKG: Constructing Technique Knowledge Graph from Cyber Threat Intelligence Reports》配套的完整模型文件集合&#xff0c;面向从事威胁情报分析、CTI结构化建模及知识图谱构建的研究人员与工程实践者。资源包含…

作者头像 李华
网站建设 2026/9/16 2:54:24

AT89C52单片机电子时钟设计与Keil-Proteus联合调试

简介&#xff1a;本资源是一套基于AT89C52单片机的数字时钟系统完整开发包&#xff0c;面向电子类专业初学者、嵌入式课程设计学生及单片机入门实践者&#xff0c;解决从原理理解、代码编写到硬件仿真验证的一体化学习需求。压缩包共25个文件&#xff0c;涵盖Keil工程&#xff…

作者头像 李华
网站建设 2026/9/16 2:54:16

1.44寸ST7735 LCD模块驱动实战:从引脚定义到C51/STM32/Arduino移植

简介&#xff1a;一套面向嵌入式学习与开发的1.44寸LCD串口模块&#xff08;ST7735&#xff09;软硬件资料包&#xff0c;覆盖C51、STM32、Arduino三大平台&#xff0c;主线清晰。资料提供各平台下的SPI驱动源码与接线说明&#xff0c;其中STM32硬件SPI测试代码区分中文、英文显…

作者头像 李华
网站建设 2026/9/16 2:52:31

AI训练师能力体系:数据闭环、模型迭代与业务对齐

1. 这不是一张证书&#xff0c;而是一套可落地的AI工程能力验证体系“阿里巴巴达摩院人工智能训练师&#xff08;高级&#xff09;”——听到这个名称&#xff0c;很多人第一反应是“又一个企业认证”&#xff0c;甚至下意识归类为“培训结业证”或“内部考核标签”。但在我连续…

作者头像 李华