这两道关于链表中点的题,我估计只要刷过LeetCode的人都见过,一个是876. 链表的中间结点,一个是2095. 删除链表的中间节点。标题里写的109,我猜多半是刷题清单里自己标的序号,或者翻译版本不同,但核心考点完全一致:怎么在单链表里找到中间那个节点,以及怎么把中间节点安全地删掉。第一次刷的时候我也觉得这题简单,不就是遍历两遍吗,后来才发现这里面的快慢指针用得相当讲究,删除中间节点时的边界条件更是能坑掉一大片人。这篇就专门把这两个题放在一起,从头到尾把思路、代码、坑,一次说透。
1. 这两道题到底在考什么
1.1 题目原型与常见编号
先对一下题号,免得有人搜错。链表的中间结点在LeetCode上的标准编号是876,题面说的是给定一个头节点 head 的非空单链表,返回链表的中间节点。如果有两个中间节点,则返回第二个中间节点。
删除链表的中间节点标准编号是2095,题面说的是给你一个链表的头节点 head,删除链表的中间节点,返回修改后的链表头节点。这里对中间节点的定义稍微有点变化:长度为偶数时,删除的是两个中间节点中的第一个。也就是说,长度n的链表,中间节点的位置是 ⌊n / 2⌋(从0开始计数),而876题返回的却是 ⌈n / 2⌉(从1开始计数的右中间)。
这两个定义上的差异,是很多人把两道题混着写之后报错的第一大原因。我自己当时写2095的时候直接套用了876的慢指针思路,结果长度为4的链表 1 -> 2 -> 3 -> 4,删出来是 1 -> 2 -> 4,但答案要求的是 1 -> 2 -> 3 -> 4 删除节点3,也就是得到 1 -> 2 -> 4,等等,这里我要仔细算一下。
长度为4时,下标分别是0、1、2、3,⌊4 / 2⌋ = 2,所以删除的是下标2的节点,也就是值为3的那个,结果为 1 -> 2 -> 4。如果我用876题的快慢指针,slow会指向下标2?不对,876题里长度为4时会返回第二个中间节点,也就是下标2的那个,恰好也是3,所以删除结果和2095要求的一样。但长度为2时,1 -> 2,2095的中间位置是 ⌊2 / 2⌋ = 1,删除下标1的节点,也就是值为2的节点,结果为 1。而876题返回的是下标1(第二个中间节点),如果直接删slow指向的节点,也删的是2,结果也是 1。这样看起来好像一致了,其实不对,因为链表的删除操作需要拿到前驱节点,直接删slow是不行的,必须想办法让指针停在待删节点的前一个位置,这就导致循环条件的处理完全不同。所以不能简单照搬876的模板。
1.2 为什么这两题值得放在一起写
因为这一对题目刚好从“查找”和“删除”两个角度考察同一个核心技能:链表的遍历与指针操作。查中间节点,是最经典的快慢指针应用;删中间节点,则是在这个基础上叠加了“前驱指针”的思维。两道题连刷,等于把链表中间节点这个考点从输入到输出的完整闭环练了一遍。
还有一个特别现实的原因:面试时很多公司喜欢出这种“表面简单、边界一堆”的题。876和2095看起来都不难,但面试官随便改个条件,比如“返回第一个中间节点”“删除中间节点的前一个节点”“链表可能为空”,你慌了没有?如果你只是背了模板答案,大概率会卡壳。这篇文章我会把从思路到边界的完整推导过程写出来,你看完可以直接拿去当面试底稿。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快慢指针:一个指针不够,就两个
2.1 链表没有下标,数组的思路全废
我见过不少刚开始刷题的朋友,拿到这个题目第一反应是:“先遍历一遍链表,数出有多少个节点,然后从头再走到一半的位置。”这个方法完全正确,时间复杂度是O(n),空间复杂度O(1),而且非常好理解。
但问题是,如果面试官说“能不能只遍历一遍?”或者“能不能不要先统计长度?”你就得换个思路了。数组之所以能快速拿到中间元素,靠的是下标随机访问,arr[mid] 一步到位;链表不行,每个节点只存了下一个节点的地址,没有“跳到任意位置”的能力。所以,链表里“找到中间节点”这件事,本身就比数组麻烦。慢,不是问题,问题是你怎么在一次遍历里同时完成“探测边界”和“锁定中点”两件事。
快慢指针的思路正是从这里来的:既然一个指针探测不了中点,那我就找两个指针,一个跑得快,一个跑得慢,快的负责探路,慢的负责记录位置。快指针到终点时,慢指针刚好走了一半,中间节点的位置就这么被“顺路”算出来了。
2.2 快慢指针的原理
具体来说,设定两个指针:slow 和 fast,都从链表头出发。slow 每次走一步,fast 每次走两步。当 fast 到达链表末尾时,slow 正好在链表中间。
为什么是这样?因为 fast 的速度是 slow 的两倍,所以相同时间内,fast 走过的路径长度是 slow 的两倍。如果链表长度为 n,fast 走 n 步到末尾,slow 就走了 n/2 步,位置正好是 n/2 下标处。这个逻辑虽然简单,但里面藏着几个问题值得抠一下:
fast到末尾时,它到底走了多少步?slow到底落在哪个位置?是左中间还是右中间?- 循环的退出条件到底怎么写,才能保证
slow指向我们想要的那个节点?
这些问题的答案,直接决定了你是写对还是写错。下面我分别拆开讲。
2.3 为什么循环条件是 fast != null && fast.next != null
写876题标准答案的时候,绝大多数人用的循环条件是:
java复制while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
理解这个条件的核心,是要明白 fast 的“落点”。一次循环里 fast 走两步,如果链表长度是奇数,比如 1 -> 2 -> 3 -> 4 -> 5,那么:
- 初始:fast在节点1。
- 第1轮:fast走到节点3,slow走到节点2。
- 第2轮:fast走到节点5,slow走到节点3。
- 此时
fast.next为null,循环终止,slow在节点3,正好是中间。
如果链表长度是偶数,比如 1 -> 2 -> 3 -> 4:
- 初始:fast在节点1。
- 第1轮:fast走到节点3,slow走到节点2。
- 第2轮:fast走到null(节点3的next是节点4,然后再next是null),slow走到节点3。
- 此时
fast == null,循环终止,slow在节点3。
长度为4,节点下标0、1、2、3,slow在节点3就是下标2,这是两个中间节点中的第二个,也正好是876题要求的返回值。所以这个循环条件写下来,天然就是“右中间”,完美契合876的题意。
那如果是2095题,删除的中间节点定义是 ⌊n / 2⌋,长度为4时要删下标2,刚才说的876慢指针最后也停在节点3(下标2),看起来一致。但你注意,2095题要原地删除节点,必须知道它的前驱——也就是下标1的节点。所以2095题不能直接用上面这个循环,它要做一点微调:让 slow 少走一步,停在待删节点的前一个位置。
3. 中间结点查找的实现与代码推导
3.1 先写一个最稳的查找函数
先把876题一步步写出来。最稳的方式就是先写一个独立的 findMiddle 函数,返回中间节点,方便测试和复用:
java复制public ListNode findMiddle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
注意一点:题目说了是非空链表,所以 head 一定不为null。如果你要写得更健壮一点,可以在一开始加一句 if (head == null) return null;,这样这个函数就能处理空链表了。
3.2 奇数/偶数长度的表现
我把几个典型长度跑一遍,方便你看清楚:
| 链表长度 | 链表内容 | fast 最终状态 | slow 最终位置 | 返回节点 |
|---|---|---|---|---|
| 1 | [1] | fast=1 | slow=1 | 1 |
| 2 | [1,2] | fast=null | slow=2 | 2 |
| 3 | [1,2,3] | fast=3 | slow=2 | 2 |
| 4 | [1,2,3,4] | fast=null | slow=3 | 3 |
| 5 | [1,2,3,4,5] | fast=5 | slow=3 | 3 |
| 6 | [1,2,3,4,5,6] | fast=null | slow=4 | 4 |
看到规律了吧:长度为偶数时,slow 停在右中间位置。876题要求的正是这个。所以这个代码放在876题上,一行都不用改,直接通过。
3.3 边界条件
边界条件最容易出问题的地方有两个。
第一,只有一个节点时,循环条件 fast != null && fast.next != null,第一轮判断时 fast.next 是null,根本不进循环,直接返回 head,正确。
第二,只有两个节点时,第一轮循环 fast.next 是节点2,不为null,进入循环:slow移到节点2,fast先到节点2,然后 fast.next.next 是null,fast变成null。循环结束,slow在节点2,返回节点2,也就是右中间,正确。
第三个容易忽略的点是:如果链表特别巨大,快指针会不会出现空指针?不会,因为循环条件里已经判断了 fast != null && fast.next != null,这两个条件都满足才往里走,所以 fast.next.next 一定不会遇到空指针。但要注意判断顺序,必须先判断 fast != null,再判断 fast.next != null,否则如果fast已经是null,你去访问 fast.next 直接就NPE了。
4. 删除中间节点:难点不在删除,在“找到前驱”
4.1 题目对“中间点”的定义不一样
2095题和876题对中间点的定义,我刚才已经提过,一个是右中间点,一个是下取整位置。但光知道这个还不够,你要真正落到代码上,还得处理“删除”这个动作本身。
链表删除节点有两种玩法。一种是直接改指针,让前驱节点指向待删节点的后继,这个需要拿到前驱。另一种是“狸猫换太子”,把待删节点的值换成后继节点的值,然后让待删节点指向后继的后继。这招在面试里偶尔会用到,但2095题你要是用这招,可能会踩一个大坑——如果待删节点是最后一个节点,它没有后继,你就没法换了。所以最稳妥的还是找前驱。
前驱在哪?在4095?不对,在2095题里,如果把 slow 定位到中间节点的前一个节点,直接写 slow.next = slow.next.next 就完事了。问题是怎么让 slow 恰好停在前驱的位置。
4.2 删除操作的核心:前驱节点
这是2095题和876题最本质的区别。876题的 slow 直接指向中间节点,而2095题需要 slow 停在中间节点的前一个位置。
要实现这个目标,最简单的办法是引入一个哑节点(dummy node)。哑节点是链表题的常规操作,它挂在真正的头节点前面,值无所谓,主要是为了让“删除头节点”这种特殊操作也能统一处理。有了哑节点,你可以让 slow 从哑节点出发,而 fast 从真正的头节点出发,这样当 fast 到达末尾时,slow 正好比“标准中间节点”慢一步,也就是停在待删节点的前一个位置。
代码是这样:
java复制public ListNode deleteMiddle(ListNode head) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode slow = dummy;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
slow.next = slow.next.next;
return dummy.next;
}
这里关键是循环条件和初始位置的配合。slow 从 dummy 出发,天然比 head 靠前一个节点,fast 从 head 出发。每次循环 slow 走一步,fast 走两步,循环结束时 fast 已经走到链表末尾(或倒数第二个节点),而 slow 走的路程比 head 出发的版本少了1,所以正好落在待删节点的前一个。
4.3 手推几个用例,确认边界
我们拿几个典型链表手动跑一下。
长度为1,链表 [1]:fast = head,进入循环判断,fast != null 为true,但 fast.next == null,所以不进循环。直接 slow.next = slow.next.next,也就是 dummy.next = null,返回null。这个结果对不对?长度为1,唯一的节点就是中间节点,删掉之后链表为空,返回null,正确。
长度为2,链表 [1, 2]:slow = dummy,fast = head。进入循环,fast != null,fast.next != null,进入循环体:slow = slow.next,也就是slow指向节点1;fast = fast.next.next,fast变成null。循环结束。此时slow在节点1,正好是待删节点2的前驱。执行 slow.next = slow.next.next,也就是节点1指向null,链表变成 [1]。长度为2,中间节点是下标1的节点2,删除后剩 [1],正确。
长度为3,链表 [1, 2, 3]:第一轮循环:slow 指向节点1,fast 指向节点3。此时 fast.next 为null,循环结束。slow在节点1,待删的中间节点是节点2,它的前驱正是节点1,执行删除后链表为 [1, 3],正确。
长度为4,链表 [1, 2, 3, 4]:第一轮:slow 指向节点1,fast 指向节点3。第二轮:fast != null 且 fast.next != null(节点3的next是节点4),进入循环:slow 指向节点2,fast 指向null。循环结束。slow在节点2,待删节点是下标2的节点3,它的前驱是节点2,删除后链表为 [1, 2, 4],正确。
这个边界设计得特别巧妙:因为2095题对中间节点的定义是 ⌊n / 2⌋,所以长度为4时删除的是节点3而不是节点2,如果照着876题的循环逻辑去删,很容易删成节点2。
5. 常见问题与易错点,刷题时亲测踩过的坑
5.1 为什么 2095 题的循环条件有时写成 fast.next.next != null
我在刷题论坛里看到过一种写法,循环条件用的是:
java复制while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
这种写法配合 slow = head 而不是 slow = dummy,也能实现“删除第一个中间节点”的效果。原理是:slow 从 head 出发,fast 从 head 出发,循环条件让 fast 至少要有两个后继才继续,这样 slow 会比876的标准位置慢一步。实际效果和哑节点方案是等价的。
我个人更推荐哑节点方案,原因有三个:第一,统一处理了空链表和单节点链表,不需要额外讨论;第二,代码语义清晰,slow 指向的就是“前驱节点”,而不是靠循环条件的微调去碰巧停在目标位置;第三,面试时你讲“用哑节点找前驱”这个思路,面试官一听就明白,不太会在边界问题上跟你纠缠。当然,两种写法都能过,看你自己习惯。
5.2 要不要手动释放被删除节点的内存
这是C/C++选手特别容易纠结的问题。在Java、Python、Go这些有垃圾回收或自动内存管理机制的语言里,slow.next = slow.next.next 之后,那个被跳过的节点失去了所有引用,GC会自动回收,不需要你管。
但在C++里,如果你用 new 创建了节点,就需要手动 delete,否则会内存泄漏。正确的处理方式是:
cpp复制ListNode* tmp = slow->next;
slow->next = slow->next->next;
delete tmp;
顺序一定不能反:先保存指针,再改链,最后释放。如果你先释放了 slow->next,再去访问 slow->next->next,那就是悬空指针,程序直接崩溃。
不过说实话,在LeetCode这种判题环境里,你就算不 delete,代码也能AC,因为每个测试用例跑完之后进程会退出,内存由操作系统回收。但如果你在真实的工程里写链表,内存管理就是必修课了,这里顺手说一下是希望大家别把刷题的坏习惯带到项目里。
5.3 面试扩展题:如果链表可能为空,或者要求删除倒数第K个节点
面试官看完你写2095,最喜欢加问的一个变体是:“如果链表可能为空,你怎么改?”这个简单,开头加一个 if (head == null || head.next == null) return null; 之类的前置判断就行。但还有一个变体值得提前准备:删除链表的倒数第N个节点(LeetCode 19)。这题也是快慢指针,但思路是让 fast 先走N步,然后 slow 和 fast 一起走,fast 走到末尾时 slow 正好在倒数第N个节点的前驱。这跟本题“快指针从头到尾走,慢指针只走一半”是同一个思想体系,练熟了你会发现自己对快慢指针的理解会深一层。
还有一个我自己面试时被问过的:不用快慢指针,怎么一次遍历找到链表的中间节点?当时我想了半天,最后只能用“递归 + 先统计长度”糊弄过去。其实如果追求一次遍历又不用快慢指针,可以用“跳到两步远”的时候把中间节点缓存下来这种思路,但本质上还是快慢指针。所以快慢指针这个解法,不仅是最优解,也是最自然的解,把它彻底吃透比背一堆花活有用得多。
6. 实操总结:两道题一起刷的正确姿势
这两道题,我的建议是放在同一天刷。先做876,找到中间节点之后不要急着关页面,想一想:“如果我要把这个节点删掉,该怎么改?”然后打开2095,自己试着写。写不出来再看题解。这样才能真正把“查找中间节点”和“删除中间节点”之间的那层窗户纸捅破。
刷的时候注意几个重点:第一,876题的关键是理解 fast != null && fast.next != null 这个条件是如何决定慢指针落在右中点的;第二,2095题的关键是理解哑节点如何让 slow 停在待删节点的前驱;第三,两个题目的中间节点定义不同,不要混用模板;第四,边界条件一定要自己手动推一遍长1、2、3、4的链表,不要只在脑子里想。
最后分享一个小技巧:我在LeetCode上刷链表题时,习惯在一开始就写好链表的打印函数或者调试用的辅助函数,遇到边界问题直接打印出链表看看。很多边界错误不是逻辑想不明白,而是眼睛看漏了。打印一遍,立刻清清楚楚。这个方法对链表这大类题目都适用,建议你也养成这个习惯。
