链表的中间结点,算法面试里出现频率极高的基础题。LeetCode 上它是第 876 题,配套的变式“删除链表的中间节点”是第 2095 题。很多人的刷题笔记里会把这两道题写成“109”,应该是自定义编号或者题库顺序记混了,不过解题逻辑是完全一致的。这篇文章把“找中间节点”和“删中间节点”放在一起讲透,覆盖两种主流解法和大量边界测试,适合刚开始刷链表的小白,也适合面试前想快速过一遍快慢指针应用的同学。
1. 题号与考点:先把题目本身看透
1.1 官方题号对照:876 与 2095
先把这个编号问题说清楚。LeetCode 官方题库里,“链表的中间结点”是第 876 题,“删除链表的中间节点”是第 2095 题。但很多资料、网盘笔记、甚至某些刷题网站的内部序号会写成“109”,或者把 876 和 2095 合并成一个编号来归档。我做题时也见过这种混乱,尤其是从旧版《算法竞赛入门经典》或某些培训机构的题库里导出的题单,编号经常对不上。建议以后碰到这种问题先看一眼题目描述,而不是纠结编号,核心永远是操作逻辑。
| 题目 | LeetCode 英文站题号 | 力扣中文站题号 | 难度 |
|---|---|---|---|
| 链表的中间结点 | 876 | 876 | 简单 |
| 删除链表的中间节点 | 2095 | 2095 | 中等 |
| 部分资料中的合并编号 | 109 | 109 | - |
1.2 中间结点问题到底在考什么
链表这种数据结构的核心特征是内存不连续,节点靠 next 指针串联,所以没法像数组那样直接用 arr[n/2] 拿到中间元素。想要定位中间节点,只能从头部开始“数着走”。这就是这类题的第一层考点:对链表遍历方式的理解。
第二层考点是快慢指针,也叫双指针。快指针一次走两步,慢指针一次走一步,当快指针到达链表尾部时,慢指针恰好停在中间。这个技巧在链表题里出现频率极高,后面做回文链表、重排链表、排序链表都会用到。
第三层考点是边界条件。空链表怎么办?只有一个节点怎么办?偶数长度时中间节点到底算左边还是右边?这些细节决定了代码能不能一次性跑对。
最后,2095 题还多考了一层:删除节点必须知道前驱节点。很多人会在这里翻车,因为快慢指针结束时 slow 停在被删节点上,而单链表没有 prev 指针,没法回头。后面我会详细讲怎么用哑节点绕开这个坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础解法:两次遍历,先数长度再定位
2.1 思路拆解
最直观的做法是分两步走:第一次遍历统计链表长度 n,第二次遍历从头走 n/2 步,落到中间节点。
为什么是 n/2?因为题目要求偶数长度时返回第二个中间节点。链表长度是 1、2、3、4 时,我们要的位置分别是下标 0、1、1、2,也就是 n // 2 的整数除法结果。这一点和数组下标习惯完全对应,用 Python 的 // 或者 C++ 的 / 都没问题。
这个解法不需要任何特殊技巧,任何人都能理解,所以非常适合作为面试时的“最笨但正确”的兜底方案。我自己面试别人的时候,也允许候选人先写这个版本,只要他能讲清楚优化方向就行。关键是别卡住,先有一个能跑的思路,再谈优化。
2.2 代码实现
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def middle_node_by_count(head):
# 第一遍:数长度
n = 0
cur = head
while cur:
n += 1
cur = cur.next
# 第二遍:从头走 n // 2 步
mid = n // 2
cur = head
for _ in range(mid):
cur = cur.next
return cur
逐行拆解一下。第一遍循环里,cur 从头走到尾,每经过一个节点就把 n 加一,循环结束后 n 就是链表总长度。第二遍用 for _ in range(mid) 从头部开始走 mid 步,这里 mid = n // 2,所以长度 5 走 2 步到第 3 个节点,长度 6 走 3 步到第 4 个节点。两个例子都符合题目对“第二个中间节点”的定义。
2.3 复杂度与局限性
这个解法的时间复杂度是 O(n),空间复杂度是 O(1)。虽然遍历了两遍,但常数级别翻倍不影响量级,所以它并不是不能用的解法。真正的局限在于,如果面试官追加一句“能不能只遍历一遍”,你就要拿出快慢指针了。
还有一个常见的次优解是:第一遍遍历的时候把所有节点存进数组,然后直接返回 arr[n // 2]。这个写法只需要一遍遍历,代码也很短,但空间复杂度变成 O(n),在链表题里通常不是最优解。我的建议是,如果笔试平台对内存没限制,数组法可以快速 AC;但面试时主动说“我能用 O(1) 空间优化”,会加分很多。
| 解法 | 时间复杂度 | 空间复杂度 | 遍历次数 | 面试推荐度 |
|---|---|---|---|---|
| 两次遍历计数 | O(n) | O(1) | 2 次 | 可作兜底 |
| 数组存节点 | O(n) | O(n) | 1 次 | 一般不推荐 |
| 快慢指针 | O(n) | O(1) | 1 次 | 推荐 |
3. 快慢指针:面试加分写法
3.1 为什么快指针偏偏走两步
快慢指针的思路可以这样理解:两个人同时从链表头部出发,慢的人每次走一步,快的人每次走两步。速度差一倍,同一段时间里快的人走的路程是慢的人的两倍。当快的人到达终点时,慢的人正好处在路程的一半位置,也就是中间节点。
换成数学表达式更清楚。设链表长度为 n,慢指针走了 s 步,快指针就走了 2s 步。快指针走完整个链表时,2s 约等于 n,于是 s 约等于 n/2。这里的“约等于”由链表的奇偶长度决定,不会影响最终落在中间节点。这就是快指针为什么是两步而不是三步:两步和一步正好构成二倍速度差,才能保证慢指针停在中点。如果快指针一次走三步,慢指针的位置就偏了。
3.2 奇偶长度和循环条件的关系
快慢指针的循环条件是最容易写错的地方。标准写法是:
python复制while fast and fast.next:
slow = slow.next
fast = fast.next.next
条件 fast and fast.next 的意思是:只要快指针当前节点存在,并且还能再走一步,就继续循环。为什么不直接写 while fast.next?因为当链表长度为偶数时,快指针最终会走到 None,此时访问 fast.next 会直接报空指针错误。加上 fast 判断就是先把“快指针已经是空”的情况拦下来。
奇数长度和偶数长度下,循环结束时机不一样。以 [1,2,3,4,5] 为例,长度为 5:初始 slow = 1,fast = 1;第一轮结束 slow = 2,fast = 3;第二轮结束 slow = 3,fast = 5;此时 fast.next 是 None,循环结束,slow 停在 3。再看 [1,2,3,4,5,6],长度 6:第一轮 slow = 2,fast = 3;第二轮 slow = 3,fast = 5;第三轮进入循环,slow = 4,fast = None,循环结束,slow 停在 4。这两个例子说明:奇数长度下 fast 停在最后一个节点,偶数长度下 fast 变成 None,但 slow 都稳稳落在目标位置。
3.3 完整代码与边界测试
python复制def middle_node(head):
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
代码只有六行左右,但边界情况要心里有数。空链表时,head 是 None,while 条件直接不成立,返回 None,符合预期。单节点链表 [1],while 也不进,返回节点 1,符合预期。长度 2 的 [1,2],进入一次循环后 slow = 2,返回第二个节点,正好是 876 题要求的第二个中间节点。
我建议你在本地把这几个用例都跑一遍:
middle_node(build([1]))返回节点 1middle_node(build([1,2]))返回节点 2middle_node(build([1,2,3,4,5]))返回节点 3middle_node(build([1,2,3,4,5,6]))返回节点 4
有一个面试中常被追问的变体:如果要求偶数长度时返回第一个中间节点,也就是左侧中点,循环条件要改成 while fast.next and fast.next.next。为什么是这样?因为 fast 每次走两步,当 fast 无法再走两步时就停下,此时 slow 会停在左侧中点。这个改动背后的逻辑值得自己想一遍,能想明白说明你真的理解了快慢指针。
3.4 常见错误清单
- 把循环条件写成
while fast.next and fast.next.next,在长度为 2 的链表上直接报错,因为第二轮访问fast.next.next时fast.next已经是None。 - 更新顺序反了,先让 fast 走两步再让 slow 走一步。虽然大多数用例能过,但在某些边界下 slow 会落后一个位置,属于运气好才对的写法。
- 忘记处理空链表,在
head为None时直接进入循环报错。 - 返回值写成 fast。fast 停在尾部或者
None,不是中间节点。
4. 变式题:删除链表的中间节点(LeetCode 2095)
4.1 题面和核心难点的变化
2095 题的要求从“返回中间节点”变成了“删除中间节点”。LeetCode 官方对“中间节点”的定义是:对于长度为 n 的链表,中间节点是从 0 开始计数时的第 ⌊n / 2⌋ 个节点。也就是说,偶数长度时删除的是偏右的那个节点。这个定义和 876 题的“返回第二个中间节点”完全一致,所以找中点的逻辑可以沿用。
但删除操作引入了新问题:单链表要删除某个节点,必须拿到它的前驱节点,然后让前驱的 next 指向被删节点的 next。快慢指针结束时,slow 正好停在被删节点上,我们没法知道它的前驱是谁。这时候有两个常用方案:一是额外用一个 prev 指针,每次 slow 移动前先记录上一个位置;二是用哑节点技巧,让 slow 从哑节点出发,这样循环结束时 slow 直接就是被删节点的前驱。
4.2 哑节点为什么好用
哑节点的做法是创建一个虚拟头节点 dummy,让 dummy.next = head,然后让慢指针从 dummy 出发,快指针从 head 出发。循环条件不变,还是 while fast and fast.next。结束时,slow 停在待删节点的前一个位置,直接执行 slow.next = slow.next.next 就能删掉目标节点。
为什么这样能统一处理所有边界?因为 dummy 的存在让头节点也有了一个“前驱”。假如中间节点恰好是 head,普通写法需要单独讨论“删完后返回 head.next”,但用了 dummy 之后,所有情况都执行同样的删除逻辑,最后统一返回 dummy.next。这就像给链表加了一个哨兵,省掉一堆 if 分支。
4.3 代码实现与逐行解释
Python 版本:
python复制def delete_middle(head):
if not head:
return None
dummy = ListNode(0, head)
slow = dummy
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
slow.next = slow.next.next
return dummy.next
有人会习惯先写 if not head or not head.next: return None,把单节点情况单独处理。但用哑节点之后,not head.next 这个判断其实可以省略。验证一下:链表 [1],dummy -> 1,fast = head = 1,while 条件中 fast.next 是 None,循环不进,然后 slow.next = slow.next.next 让 dummy.next 变成 None,返回 None,结果正确。不过加一个判断也不影响正确性,只是代码更冗余。我自己写的时候只保留 if not head,因为这是唯一会导致空指针的情况。
C++ 版本:
cpp复制class Solution {
public:
ListNode* deleteMiddle(ListNode* head) {
if (!head) return nullptr;
ListNode dummy(0, head);
ListNode* slow = &dummy;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
ListNode* toDelete = slow->next;
slow->next = slow->next->next;
delete toDelete; // 刷题时不写也行,工程上必须释放
return dummy.next;
}
};
C++ 版本里我额外写了一句 delete toDelete。LeetCode 在线评测通常不检查内存泄漏,不写也能过,但面试时主动释放被删节点,能体现你对内存管理的意识。实际工程里,链表节点的删除一定要考虑释放问题,否则长时间运行会内存泄漏。
4.4 边界用例推演
| 输入链表 | 期望输出 | 说明 |
|---|---|---|
| [1] | null | 中间节点就是头节点,删完链表为空 |
| [1,2] | [1] | 中间节点是 2(下标 1),删除后只剩 1 |
| [1,2,3] | [1,3] | 中间节点是 2,删除后 1 -> 3 |
| [1,2,3,4] | [1,2,4] | 中间节点是 3(下标 2),删除后 1 -> 2 -> 4 |
| [1,2,3,4,5] | [1,2,4,5] | 中间节点是 3,删除后 1 -> 2 -> 4 -> 5 |
以 [1,2,3,4] 为例完整推演一遍。初始 dummy -> 1,slow = dummy,fast = 1。第一轮:slow 移到 1,fast 移到 3。第二轮:slow 移到 2,fast 移到 None,循环结束。此时 slow 是节点 2,恰好是待删节点 3 的前驱。执行 slow.next = slow.next.next,也就是让 2 的 next 指向 4,链表变成 1 -> 2 -> 4。这个推演过程建议你亲手在草稿纸上画一遍,比看十遍代码都有用。
5. 经验总结:这类题怎么练才不会忘
5.1 快慢指针题目家族
做完了 876 和 2095,你会发现快慢指针在链表题里几乎是无处不在的套路。以“找中点”为基础动作,可以延伸出一整串题目:
| 题目 | 题号 | 与快慢指针的关系 |
|---|---|---|
| 环形链表 | 141 | 快慢指针,快指针追上慢指针则说明有环 |
| 环形链表 II | 142 | 快慢指针相遇后,再用双指针找环入口 |
| 重排链表 | 143 | 找中点 + 反转后半段 + 交替合并 |
| 排序链表 | 148 | 找中点切分链表,配合归并排序 |
| 回文链表 | 234 | 找中点 + 反转后半段 + 逐节点比较 |
你会发现这些题的共同套路都是:先用快慢指针把链表一分为二,再对后半段做反转、比较或者合并。所以 876 题不仅仅是三道简单题,它是后面一堆中等题的基础。我刷题时有一个习惯,每做完一道题,就去题库里找它的“变式家族”,按套路归类,这样比盲目刷题效率高很多。
5.2 面试现场怎么组织答案
面试遇到这道题,我建议按这个节奏来回答。
第一,先复述题意并确认边界。问清楚“如果链表长度是偶数,返回第一个还是第二个中间节点”。面试官可能会故意把条件改一下,考察你能不能随机应变。
第二,从暴力解法讲起。先说“我可以先遍历一遍数长度,再走 n/2 步”,并说明复杂度是 O(n) 时间、O(1) 空间,两遍遍历。
第三,提升到快慢指针。说“我可以让一个指针每次走两步,另一个每次走一步,这样只需要一遍遍历”,并解释为什么快指针停下时慢指针在中点。这时候面试官一般会点头。
第四,写代码。写的时候口述每一步在做什么,比如“先判断空链表”“fast 能走两步才移动 slow”“最后返回 slow”。边写边解释比闷头写要好。
第五,主动跑边界用例。写完代码后用空链表、单节点、长度 2、长度 5、长度 6 各跑一遍,这一步能直接展示你的严谨性。
第六,如果被追问删除版本,再把哑节点的优势讲出来。这一整套流程下来,面试官很难挑出毛病。
5.3 本地调试链表的两个小工具
刷链表题最痛苦的是看不到中间状态,调试全靠想象。我建议你本地准备两个辅助函数,一个是数组转链表,一个是链表转数组,能省下大量时间。
python复制def build_link(vals):
dummy = ListNode(0)
cur = dummy
for v in vals:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
def link_to_list(head):
res = []
while head:
res.append(head.val)
head = head.next
return res
有了这两个函数,就可以直接写断言来验证答案。比如:
python复制assert link_to_list(middle_node(build_link([1,2,3,4,5]))) == [3,4,5]
assert link_to_list(delete_middle(build_link([1,2,3,4]))) == [1,2,4]
实测下来,这种断言式调试比 print 大法更直观,因为每次跑完能立刻知道哪里错了。后来我刷所有链表题都用这一套模板,效率提升很明显。
5.4 每次写链表题前的自查清单
| 检查点 | 容易错的地方 | 处理方式 |
|---|---|---|
| 空链表 | 访问 fast.next 报空指针 | 开头先判 head is None |
| 单节点 | 删除后返回什么 | 哑节点写法自动处理,返回 dummy.next |
| 偶数长度 | 中间节点定义不一致 | 写之前和面试官确认,再选循环条件 |
| 删除节点 | 忘记被删节点的前驱 | 哑节点或 prev 指针二选一 |
| 更新顺序 | fast 和 slow 移动顺序反了 | 固定先 slow 后 fast,形成肌肉记忆 |
最后再分享一个小技巧。如果你实在记不住奇偶长度下指针的落点,就在草稿纸上画一个长度为 4 的链表,把 slow 和 fast 每一步的位置标出来,走一遍就再也不怕了。这个办法是我早期刷链表题时用的,后来每次遇到不确定的边界,我都会画一遍再写代码,几乎没出过错。
