链表题在算法面试里有个奇怪的地位:说不难吧,很多人一写就崩;说难吧,核心操作无非就是遍历、插入、删除那几下。LeetCode上跟“中间结点”相关的两道题——定位链表的中间节点和删除链表的中间节点——恰好把“找节点”和“删节点”这两个链表基本动作串在了同一条线上。相关热搜词长期霸榜不是没道理的,因为这两道题是很多链表综合题的底座,面试官也喜欢拿它们当热身题,看看你对指针操作和边界条件敏不敏感。这篇就把它们合在一起拆透:从暴力解法推到快慢指针,再到删除中间节点时必须解决的“前驱节点”问题,最后给出可运行的完整代码和一套边界测试用例。适合刚刷链表题的新手,也适合准备面试前快速回顾的人。
顺带说一句,标题里写“Leetcode 109”的话,搜出来的其实是另一道题,官方题库中“链表的中间结点”编号是876,“删除链表的中间节点”编号是2095。题号记岔很正常,关键是把这两道题背后的套路吃透,编号不影响你掌握知识点。
1. 从两道“同源题”说起:定位和删除之间的关系
1.1 两道题到底在问什么
先明确题目要求,这是所有链表题的第一步。
LeetCode 876“链表的中间结点”:给你单链表的头节点 head,找出并返回链表的中间节点。如果有两个中间节点,则返回第二个中间节点。这个“两个就返回第二个”的约定很关键,后面快慢指针的循环条件就是照着它设计的。
LeetCode 2095“删除链表的中间节点”:给你链表的头节点 head,删除链表的中间节点,然后返回修改后的链表的头节点。同样,如果有两个中间节点,删除第二个。节点数至少为1。
这两道题放在一起看,逻辑关系非常直接:2095的第一步就是876——先找到中间节点;第二步是标准的链表删除。但这里有个坑:如果你真的先把“中间节点”单独找出来,然后想原地删掉它,会发现没有前驱节点信息,删不了。所以删除中间节点的题,真正考验的不是“找中间节点”,而是“找中间节点的前驱节点”。
1.2 为什么这两道题适合一起练
我个人的体会是,链表题可以按“操作链路”来分组复习。876和2095正好构成一条完整链路:定位节点 -> 修改前驱指针 -> 释放节点内存。把这条链路走一遍,后面再做“删除链表倒数第N个节点”“两两交换链表中的节点”这类题,思路会顺很多。
另外,这两道题能把一个很重要的思维习惯练出来:链表操作之前,先搞清楚你到底需要哪个节点的信息。删除节点需要的是前驱,翻转链表需要的是后继,找中间节点需要的是一个能稳定停在中间的游标。把这句话刻在脑子里,比背十道题解都管用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快慢指针的推导过程与临界条件较真
2.1 朴素解法:两遍遍历的慢在哪
先看最直观的解法。第一遍遍历数出链表长度 n,第二遍从头走 n / 2 步(整数除法,向下取整),停下的节点就是中间节点。C++ 写出来大概这样:
cpp复制ListNode* middleNode(ListNode* head) {
int n = 0;
ListNode* cur = head;
while (cur) {
++n;
cur = cur->next;
}
cur = head;
for (int i = 0; i < n / 2; ++i) {
cur = cur->next;
}
return cur;
}
这个解法的好处是极其直观,不容易错,时间复杂度 O(n),空间复杂度 O(1)。坏处是链表被遍历了两次。单从算法复杂度看,两次遍历和一次遍历都是 O(n),量级没区别,但那一次额外的遍历在一些大厂面试官眼里就是不够优雅。他们想听的是快慢指针。
2.2 快慢指针为什么能一次遍历找到中点
快慢指针的思路很朴素:让两个指针都从链表头出发,慢指针 slow 每次走一步,快指针 fast 每次走两步。因为 fast 的速度是 slow 的两倍,所以当 fast 到达链表末尾时,slow 刚好走了一半,停下的位置就是中间节点。
文字描述可能不够直观,画个 ASCII 图。假设链表是 1 -> 2 -> 3 -> 4 -> 5:
code复制1 -> 2 -> 3 -> 4 -> 5
s
f
1 -> 2 -> 3 -> 4 -> 5
s f
1 -> 2 -> 3 -> 4 -> 5
s f(此时 f->next 为空,退出)
slow 停在节点 3,正好是中间节点。
再看偶数长度的情况,链表是 1 -> 2 -> 3 -> 4 -> 5 -> 6:
code复制1 -> 2 -> 3 -> 4 -> 5 -> 6
s
f
1 -> 2 -> 3 -> 4 -> 5 -> 6
s f
1 -> 2 -> 3 -> 4 -> 5 -> 6
s f
1 -> 2 -> 3 -> 4 -> 5 -> 6
s f(此时 f 为 nullptr,退出)
slow 停在节点 4,也就是两个中间节点中的第二个。正好符合题目“返回第二个中间节点”的要求。
用数学语言概括:链表长度 n 为奇数时,n = 2k + 1,fast 走 k 轮到达最后一个节点,slow 走 k 步到达第 k + 1 个节点,这是正中间;n 为偶数时,n = 2k,fast 走 k 轮变成 nullptr,slow 走 k 步到达第 k + 1 个节点,这是两个中间节点中的第二个。两个情况都符合题意。
2.3 奇偶长度和循环条件的对应关系
写代码时,循环条件必须写成 while (fast && fast->next),顺序不能反。这个条件同时处理了两种情况:
- 链表长度为奇数:最后一轮循环结束时,
fast指向最后一个节点,fast->next为nullptr,条件不成立,退出。 - 链表长度为偶数:最后一轮循环结束后,
fast变为nullptr,条件不成立,退出。
如果你把条件写成 while (fast->next && fast),看着差不多,但一旦 fast 是空指针,访问 fast->next 直接触发空指针异常。C++ 的 && 运算符是短路求值的,所以必须先判断 fast 不为空,再去访问它的 next 字段。
对应关系的表格可以整理成下面这样,方便面试前快速过一遍:
| 链表长度 | 例子 | fast 结束时状态 | slow 停在 | 是否为题目要求的中间节点 |
|---|---|---|---|---|
| 1(奇数) | [1] | 指向节点1 | 节点1 | 是 |
| 2(偶数) | [1,2] | nullptr | 节点2 | 是(第二个中间节点) |
| 3(奇数) | [1,2,3] | 指向节点3 | 节点2 | 是 |
| 4(偶数) | [1,2,3,4] | nullptr | 节点3 | 是(第二个中间节点) |
| 5(奇数) | [1,2,3,4,5] | 指向节点5 | 节点3 | 是 |
这里有个容易被忽略的点:当链表只有 1 个节点时,初始状态 slow 和 fast 都指向这个节点,循环一次都不会执行,直接返回 slow,也就是它自己。这个行为是符合题意的——唯一节点就是中间节点。
cpp复制ListNode* middleNode(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
这段代码短到令人发指,但能考察的东西一点也不少:循环条件的顺序、快慢指针的相对位置、退出时 slow 落在哪个节点。面试时能把这三件事讲清楚,比背一百行花哨代码有用。
3. 删除中间节点:真正考验功底的是“前驱节点”这一步
3.1 链表删除的本质是修改前驱指针
先回到链表删除的基本原理。单链表是单向的,每个节点只保留指向后继节点的指针。要删除节点 B,A 的指针必须绕过 B 直接指向 C,也就是执行 A->next = B->next。这个操作完成后,B 在逻辑上已经不在链表里了,如果用的是 C++ 这种手动管理内存的语言,还需要 delete B 释放内存。
问题在于:要执行 A->next = B->next,你必须先拿到 A。而单链表只能从 head 往后走,拿到 B 并不代表能拿到 A。这就是为什么 2095 不能简单地“先调用 876 找到中间节点,再删掉它”。
3.2 让慢指针停在“前驱”上的写法
解法其实很巧妙:快慢指针找中间节点的时候,让 slow 从哑节点出发,而不是从 head 出发。这样当 fast 走到末尾时,slow 停的位置正好是中间节点的前驱。
先说哑节点是什么。哑节点是一个不存数据的辅助节点,它的 next 指向真正的链表头。在链表头部操作时,用哑节点可以省掉很多“头节点是不是需要特判”的烦恼。Python 里创建哑节点的常见写法是 dummy = ListNode(0, head),C++ 里是 ListNode* dummy = new ListNode(0, head)。
具体到删除中间节点这道题,代码是这样:
cpp复制ListNode* deleteMiddle(ListNode* head) {
if (!head || !head->next) {
return nullptr;
}
ListNode* dummy = new ListNode(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 = toDelete->next;
delete toDelete;
return head;
}
这里 slow 从 dummy 开始,fast 从 head 开始。slow 走的步数比从 head 出发时少一步,所以最后停下来时,它指向的不是中间节点,而是中间节点的前驱。用上面的例子验证一下。
链表 1 -> 2 -> 3 -> 4 -> 5,初始 slow = dummy,fast = 1。
- 第一轮:
slow = 1,fast = 3 - 第二轮:
slow = 2,fast = 5 fast->next为nullptr,退出
此时 slow 指向节点 2,也就是中间节点 3 的前驱。执行 slow->next = toDelete->next,把 3 跳过,链表变成 1 -> 2 -> 4 -> 5。正确。
链表 1 -> 2 -> 3 -> 4 -> 5 -> 6,初始 slow = dummy,fast = 1。
- 第一轮:
slow = 1,fast = 3 - 第二轮:
slow = 2,fast = 5 - 第三轮:
slow = 3,fast = nullptr
此时 slow 指向节点 3,它是中间节点 4 的前驱。删除 4 后,链表变成 1 -> 2 -> 3 -> 5 -> 6。也是正确的。
3.3 为什么用哑节点而不是单独特判
可能有读者会问:不用哑节点行不行?也行,但要多写一个分支。比如你可以先用快慢指针找到中间节点,同时用一个 prev 变量记录 slow 的前一个节点,然后用 prev 来执行删除。但这样写有个麻烦:当链表只有一个节点时,prev 是 nullptr,你得先特判“直接从空链表返回”。当链表只有两个节点时,中间节点是第二个节点,它的前驱是第一个节点,这时候又不特判了。这种边界情况的判断很容易漏。
用哑节点的好处是,所有删除操作统一成“修改 slow->next”,不管是删除头节点、中间节点还是尾节点,逻辑都一样,不需要关心 head 本身的特殊情况。代码可读性更高,也不容易在边界条件上翻车。
你可能还会问:为什么删完节点之后要 delete toDelete?这是 C++ 的内存管理要求。链表节点是 new 出来的,堆内存不会自动释放,如果不 delete,就造成内存泄漏。在 LeetCode 上跑题不 delete 也能过,因为平台只检查结果,但这不代表你可以养成坏习惯。本地工程里要是这么写,跑几次内存就涨上去了。用 Python 写就没有这个烦恼,垃圾回收器会自动处理。
4. 可运行的完整代码与测试用例设计
4.1 C++ 完整实现
把 876 和 2095 放在一起,写一个完整可运行的 C++ 程序。除了两个核心函数,还要包含构造链表、打印链表的辅助函数。
cpp复制#include <iostream>
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;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
ListNode* deleteMiddle(ListNode* head) {
if (!head || !head->next) {
return nullptr;
}
ListNode* dummy = new ListNode(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 = toDelete->next;
delete toDelete;
return head;
}
ListNode* buildList(std::initializer_list<int> vals) {
ListNode dummy(0);
ListNode* tail = &dummy;
for (int v : vals) {
tail->next = new ListNode(v);
tail = tail->next;
}
return dummy.next;
}
void printList(ListNode* head) {
while (head) {
std::cout << head->val;
if (head->next) std::cout << " -> ";
head = head->next;
}
std::cout << std::endl;
}
int main() {
ListNode* l1 = buildList({1, 2, 3, 4, 5});
std::cout << "876: ";
std::cout << middleNode(l1)->val << std::endl;
ListNode* l2 = buildList({1, 2, 3, 4, 5});
ListNode* res = deleteMiddle(l2);
std::cout << "2095: ";
printList(res);
return 0;
}
输出结果:
code复制876: 3
2095: 1 -> 2 -> 4 -> 5
4.2 Python 完整实现
Python 版本的代码更简洁,主要写清楚逻辑,不用操心内存释放的问题。
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def middle_node(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
def delete_middle(head):
if not head or not head.next:
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 head
def build_list(lst):
dummy = ListNode(0)
tail = dummy
for v in lst:
tail.next = ListNode(v)
tail = tail.next
return dummy.next
def print_list(head):
vals = []
while head:
vals.append(str(head.val))
head = head.next
print(" -> ".join(vals))
l1 = build_list([1, 2, 3, 4, 5])
print("876:", middle_node(l1).val)
l2 = build_list([1, 2, 3, 4, 5])
print("2095:", end=" ")
print_list(delete_middle(l2))
Python 里 slow.next = slow.next.next 这一行执行后,被删除的节点没有变量引用,垃圾回收器会自动回收它的内存,不需要手动处理。
4.3 测试用例覆盖了哪些边界
一道链表题能不能一次写对,很大程度上取决于你准备了多少边界用例。我的习惯是固定覆盖这几种情况:
| 输入链表 | 876 期望输出 | 2095 删除后期望结果 | 考察点 |
|---|---|---|---|
| 空链表 | nullptr | 不适用(题目保证至少1个节点) | 空指针处理 |
| [1] | 1 | 空链表 | 单节点特判 |
| [1,2] | 2 | [1] | 两个中间节点取第二个 |
| [1,2,3] | 2 | [1,3] | 奇数长度 |
| [1,2,3,4] | 3 | [1,2,4] | 两个中间节点取第二个 |
| [1,2,3,4,5] | 3 | [1,2,4,5] | 奇数长度,常规情况 |
| [1,2,3,4,5,6] | 4 | [1,2,3,5,6] | 偶数长度,常规情况 |
为什么单节点和双节点必须单独测?因为这两个用例最容易暴露“前驱节点”处理的漏洞。比如你写删除逻辑时,如果 prev 是空指针或者 slow 停的位置不对,这两个用例立刻报错。
我在本地跑的时候还发现一个容易掉进去的坑:LeetCode 上 2095 题的一个测试用例是长度为 1 的链表,删除中间节点后应该返回空链表。如果你在 deleteMiddle 开头没有写 if (!head || !head->next) return nullptr;,那这个用例就会出错,因为快慢指针初始化为 fast = head 后,fast->next 是空,循环进不去,最后 slow->next = slow->next->next 这行会访问空指针的 next。这就是单节点特判存在的意义。
5. 链表边界条件的坑与工程联想
5.1 循环条件写反,编译器不会救你
while (fast && fast->next) 和 while (fast->next && fast) 在绝大多数情况下行为一致,但在 fast 为空时,第二个写法直接访问空指针的成员变量,程序立刻崩溃。表面上看只是把两个条件的顺序换了一下,实际上触发了未定义行为。
在 LeetCode 上遇到这种情况,你会收到一个 AddressSanitizer: heap-buffer-overflow 之类的错误提示,新手很容易懵,不知道是自己哪里越界了。其实解决方法很简单:只要记住“访问指针的 next 之前,必须先保证这个指针本身不为空”这一条铁律,循环条件就不会写反。
5.2 手动管理内存的工程习惯
C++ 刷题和写工程是两码事。LeetCode 平台只检查链表逻辑是否正确,节点内存有没有释放它不关心。但到了本地工程里,如果服务端代码用 new 创建节点却不释放,长时间运行下来内存占用会稳定上升,最后把进程拖死。
我在实际工程里见过类似的问题。某个模块用链表维护一批任务节点,删除任务时只改了指针指向,没有 delete 节点。刚开始测试时数据量小,看不出问题;压测跑了一个晚上,内存直接涨到几个 GB。排查起来也很费劲,因为它的表现不是立刻崩溃,而是逐渐变慢,最后 OOM。
所以刷 2095 这类题目时,我建议 C++ 选手养成 delete 的好习惯。虽然在这个特定的题目里,不 delete 也能通过所有测试用例,但等到你真的去写一个常驻进程的内存管理代码时,这个习惯能帮你躲开一堆莫名其妙的坑。Python、Java 这类带垃圾回收引擎的语言,就不需要纠结这一点,指针断开后对象自动等回收。
5.3 快慢指针不止能找中点
聊完这两道题,再往远处看一眼。快慢指针这个技巧在链表题里是个很通用的工具,除了找中间节点,它还能干这些事:
- 检测链表是否有环:Floyd 判圈算法。
slow每次走一步,fast每次走两步,如果链表有环,两个指针最终会在环里相遇;如果没有环,fast会先走到nullptr。 - 找倒数第 N 个节点:让
fast先走 N 步,然后slow和fast同步走,fast到末尾时slow正好停在倒数第 N 个节点。删除链表的倒数第 N 个节点这道题,就是这么做的,而且同样需要哑节点。 - 判断链表是否成环并找环的入口:在 Floyd 判圈的基础上,相遇后把一个指针移回
head,两个指针各走一步,再次相遇的位置就是环的入口。
删除中间节点这道题里的哑节点技巧,在很多需要操作头节点的场景里都会用到。比如“删除链表的倒数第 N 个节点”“合并两个有序链表”“反转链表 II”这类题,哑节点都能帮你省掉一些丑陋的 if 判断。可以说,876 和 2095 这两道题刷明白,等于同时把“快慢指针”和“哑节点”这两个链表高频技巧一起收了。
我个人的刷题习惯是:拿到任何链表题,先别动键盘,在脑子里把“空链表、单节点、双节点”这三个用例先后跑一遍,确认操作不会访问空指针、不会把头节点弄丢,再开始写代码。光这一条,就能帮你避开面试里至少一半的边界情况追问。如果再补上“删除节点时要找到前驱”“修改指针前先保留下一步的地址”这两条,链表题基本就稳了。
