1. 分割链表到底在考什么
1.1 先看懂题目:手跑一遍最直观
力扣上的“分隔链表”这道题,题号是86,英文名Partition List。题面不长,一句话就能说清楚:给定一个链表和一个值x,把所有小于x的节点放到大于或等于x的节点前面,并且保持每一段里节点的原始相对顺序。
如果你第一次接触,我建议先别急着看题解,拿笔手写一个例子跑一遍。比如:
原链表:1 -> 4 -> 3 -> 2 -> 5 -> 2,x = 3
按题意拆开看:
- 小于3的节点:1、2、2
- 大于等于3的节点:4、3、5
再按题目要求拼起来:1 -> 2 -> 2 -> 4 -> 3 -> 5
你发现没有,这题不是排序。结果里4和3仍然保持着原顺序(4在3前面),2和5也保持着原顺序。真正做的只是按x划了一道分界线,左边的都小于x,右边的都大于等于x。
很多人容易在这里想歪,总以为要排序。一旦去排序,相对顺序就没法保证,复杂度也从O(n)变成O(n log n),完全是往坑里走。
1.2 为什么别急着转数组
我见过不少人在刷链表题时有个习惯,第一反应是把链表转成数组,处理完再重建链表。对某些题这确实能过,但对这道分割链表,强烈不建议。
原因很简单。转数组开辟了O(n)的额外空间,而且丢失了这题真正想考的点。力扣为什么爱出链表题?因为它考察的是指针操作、引用赋值、边界处理这些基本功。你用数组做,相当于绕过了考点。面试的时候考官很大概率会追问一句:“能不能不额外开空间?”到那时候你再临时想指针解法,心态就崩了。
而且从链表本身的性质来看,它天生适合原地调整。节点还是那些节点,不需要新建,只需要改一改next的指向。这就是“拆”和“接”的艺术。
1.3 最正统的解法:两个哨兵链表
这道题最经典的叫法是“双链表法”或者“双指针法”,本质上是用两个哨兵节点(dummy node)分别维护两条新链。
具体来说:
- 一条链收集所有小于x的节点
- 一条链收集所有大于等于x的节点
- 遍历完原链表后,把两条链拼接起来
为什么要用哨兵节点?因为你会发现原链表的头节点可能小于x,也可能大于等于x。如果不用哨兵,你在往两条链里挂第一个节点时,就得专门判断“当前链是不是空的”,代码会变丑,边界条件也会变多。用哨兵之后,所有节点都统一往“尾部”挂,最后返回哨兵的next就行,省去了一堆if判断。
你要记住一个感觉:哨兵节点就像修路时路边插的临时路标,它不参与最终结果,但帮你把方向标清楚了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双链表法完整实现,逐行拆给你看
2.1 Python版本:刷题最顺手的写法
python复制# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
class Solution:
def partition(self, head: Optional[ListNode], x: int) -> Optional[ListNode]:
small = ListNode(-1)
large = ListNode(-1)
p_small = small
p_large = large
cur = head
while cur:
if cur.val < x:
p_small.next = cur
p_small = p_small.next
else:
p_large.next = cur
p_large = p_large.next
cur = cur.next
p_large.next = None
p_small.next = large.next
return small.next
如果你看我上面写的,会发现真正核心的代码不到十行。但每一行都有讲究。
第10行的ListNode(-1)就是dummy节点,我习惯用-1或者0,因为题目里节点值范围不影响这个哨兵本身。p_small和p_large两个尾指针,分别指向两条链的当前尾部。遍历的时候,cur每走到一个节点,按值大小决定挂到哪条链上,然后把对应的尾指针往后挪一步。
第20行p_large.next = None是断尾操作,没有这一步你会被环搞疯。第21行把大值链接到小值链的尾部,第22行返回的是small.next,而不是small。这些点下面细说。
2.2 C++版本:面试手撕时的高频写法
cpp复制/**
* Definition for singly-linked list.
* 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) {}
* };
*/
class Solution {
public:
ListNode* partition(ListNode* head, int x) {
ListNode* smallDummy = new ListNode(0);
ListNode* largeDummy = new ListNode(0);
ListNode* smallTail = smallDummy;
ListNode* largeTail = largeDummy;
ListNode* cur = head;
while (cur != nullptr) {
if (cur->val < x) {
smallTail->next = cur;
smallTail = cur;
} else {
largeTail->next = cur;
largeTail = cur;
}
cur = cur->next;
}
largeTail->next = nullptr;
smallTail->next = largeDummy->next;
ListNode* result = smallDummy->next;
delete smallDummy;
delete largeDummy;
return result;
}
};
C++版本和Python版本思路完全一致,区别在于内存管理。new出来的dummy节点用完要delete。
这里有个面试官可能会瞄一眼的点:delete smallDummy和largeDummy会不会影响结果链?不会。delete释放的是dummy节点本身,而返回结果用的是smallDummy->next,这个指针在delete之前已经存到result了。dummy节点不在结果链里,所以释放它们是安全的。
2.3 三个关键细节,错了就全盘崩
细节一:断尾操作。p_large.next = None这行,新手特别容易漏。为什么要断尾?链表节点是复用的,原链表里最后一个节点的next可能是null,也可能不是。但在拆链过程中,节点之间原来的连接关系还残留着。比如原链表1 -> 4 -> 2 -> 3,x = 3,遍历后小值链是1 -> 2,但2的next还指着3;大值链是4 -> 3。如果不把3的next置空,拼接时就会出现环,LeetCode会直接报错。所以凡是把链表拆开重组,最后一定要检查:还有没有哪个节点的next指向了不该指的地方。
细节二:返回的是small.next,不是small,也不是head。head在遍历过程中已经被拆散,原头节点可能被挂到了大值链上,你返回head只会得到一段残缺链表。small是dummy节点,它不是有效节点,必须返回它的next。这属于哨兵节点的基本使用习惯,多写几次就条件反射了。
细节三:C++里new出来的dummy要delete。虽然力扣的判题环境不写delete也能过,但面试时如果写C++,建议养成手动释放的习惯。这不是炫技,而是工程素养。
2.4 复杂度与面试追问怎么应对
时间复杂度O(n),一次遍历完成所有操作。空间复杂度O(1),只申请了两个dummy节点,没有额外开数组或哈希表。这个复杂度是所有链表题里的标杆答案。
面试官如果追问“这个解法稳定吗”,你可以回答稳定的。稳定性体现在:遍历顺序就是原链表顺序,挂到两条链时没有改变节点的相对顺序,所以输出结果中每段内部的相对顺序和原链表完全一致。这比排序算法讨论稳定性要有趣得多——链表题里“保持相对顺序”往往是题目硬性要求,你的解法天然满足,这本身就是设计巧妙的地方。
追问升级版:“如果不允许新建任何节点呢?”这个问题其实是个陷阱,因为双链表法压根没有新建节点,只是new了两个dummy头节点,dummy不算业务节点。如果你想更严谨,可以说“不创建任何业务节点,只创建两个哨兵”,面试官一般会满意。
3. 分割链表的三胞胎:86题、725题、328题对比
3.1 力扣725:按长度把链表切成k段
如果说86题是按值切,那725题“分隔链表”(Split Linked List in Parts)就是按长度切。题目要求:把给定链表切分成连续k段,每段长度尽量平均,前面的段可以比后面的段多一个节点。
这道题拿到手,第一步永远是数长度。数完长度之后,算两个关键数字:
- base = n // k,每段的基础长度
- extra = n % k,前extra段需要多分一个节点
然后从头开始切段,每切完一段,要把当前段尾部的next置空,再接着切下一段。这里有一个需要注意的边界:如果k大于链表长度,后面几段就是空链表,需要append(None)。
python复制class Solution:
def splitListToParts(self, head: Optional[ListNode], k: int) -> List[Optional[ListNode]]:
cur = head
n = 0
while cur:
n += 1
cur = cur.next
base = n // k
extra = n % k
res = []
cur = head
for i in range(k):
part_len = base + (1 if i < extra else 0)
if part_len == 0:
res.append(None)
continue
res.append(cur)
for j in range(part_len - 1):
cur = cur.next
nxt = cur.next
cur.next = None
cur = nxt
return res
踩坑点:切段的时候,很多人会忘记“先保存下一段的头,再断尾”。如果先执行cur.next = None,下一段的起点就找不到了,因为cur.next已经被切断。顺序很重要:先nxt = cur.next,再cur.next = None,最后cur = nxt。
3.2 力扣328:按奇偶位置重排链表
328题“奇偶链表”(Odd Even Linked List)也是分割思路的变种,它的分割依据是下标位置:把奇数位置的节点放前面,偶数位置的节点放后面。注意这里的奇数偶数指的是位置,不是节点值。
这个题的思路和86题神似,只不过不需要dummy节点了,因为原链表的头节点一定属于奇数链,奇数链的头就是head,偶数链的头就是head.next,都是确定的。
python复制class Solution:
def oddEvenList(self, head: Optional[ListNode]) -> Optional[ListNode]:
if not head or not head.next:
return head
odd = head
even_head = head.next
even = even_head
while even and even.next:
odd.next = even.next
odd = odd.next
even.next = odd.next
even = even.next
odd.next = even_head
return head
核心逻辑是交替拆接:odd.next指向even.next,偶数节点串到奇数节点后面;奇数节点串好之后,even.next指向新的odd.next。循环结束后把奇数链尾部接到偶数链头部。
循环条件用while even and even.next,能同时兼容链表长度为奇数和偶数两种情况。如果你是新手,建议拿1 -> 2 -> 3 -> 4 -> 5这种例子手动走一遍,走着走着就明白了为什么条件是even and even.next。
3.3 三道题一眼看懂的对比表
| 题号 | 分割依据 | 是否用dummy | 核心操作 | 空间复杂度 |
|---|---|---|---|---|
| 86 分隔链表 | 节点值是否小于x | 是,两个dummy | 按值挂链,最后拼接 | O(1) |
| 725 分隔链表 | 节点位置/数量 | 否 | 数长度,算商和余数,切段 | O(1) |
| 328 奇偶链表 | 节点位置奇偶 | 否 | 奇偶指针交替拆接,最后拼接 | O(1) |
三兄弟一摆,规律一下就出来了:都是先拆后接。区别只在“拆的依据”和“拼接方式”上。理解了这一点,看到新题时你就能快速识别出“这题是不是换皮的分割链表”。
3.4 所有分割题的通用套路:先拆后接
把这三道题放在一起复盘,能提炼出一个抽象模板:
- 准备哨兵节点或者固定头节点
- 遍历原链表,按规则把每个节点挂到对应的链/组
- 处理尾部节点的next,防止成环
- 拼接各部分
这个模板不止适用于分割链表,还适用于很多链表题。比如反转链表区间、链表排序(归并排序),本质都是拆拆接接。你把“拆-接”这两个动作用熟了,链表题基本就打通了一半。
4. 刷题路上的坑,我帮你踩过了
4.1 高频问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 报错cycle detected | 大值链尾部没有置空,或者拼接后残留环形引用 | 在拼接前执行p_large.next = None |
| 输出结果为空 | 返回了dummy节点而不是dummy.next | 改成return small.next |
| 输出结果和原链表一样 | while条件写错,比如cur.next代替cur | 检查遍历循环条件,确保cur每次前进 |
| 结果顺序不对 | 把>=x的条件写成>x,导致等于x的节点放错边 | 按题目要求,等于x的节点放右边 |
| 程序运行超时 | 大概率是链上出现了环,遍历无法结束 | 检查所有next的断尾和拼接逻辑 |
第1条是我个人吃过亏的地方。力扣报cycle detected的时候,甚至不会告诉你具体哪个节点出了问题,只能自己一行行查。后来养成习惯:只要涉及链表重连,写完代码第一件事就是检查“最后一个节点的next到底指向哪”。
4.2 边界条件checklist,刷题前默念一遍
每次提交前,脑子里过一遍这些极端情况:
- 空链表:head为None,应该直接返回None
- 单节点链表:只有一个节点且小于x,或大于等于x,结果都应该是它本身
- 所有节点都小于x:大值链为空,结果就是原链表
- 所有节点都大于等于x:小值链为空,结果还是原链表
- x不在链表中:比如x=10但链表值最大只有5,所有节点按原顺序排在最后,结果不变
- 多个值等于x:等于x的节点必须全部放右边,且保持原有相对顺序
- 链表中值有负数:不影响逻辑,但初始值如果是0的话记得别写在dummy的val里误导自己
这些case不用全写代码,心里过一遍就行。真正报错的时候,你才知道边界条件有多值钱。
4.3 一个能立刻用的本地调试模板
力扣的在线编辑器只能看到输出结果,看不到中间状态。想打印链表当前长什么样?我给你一个可以直接跑的模板,平时练习强烈建议用本地环境。
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_linked_list(arr):
dummy = ListNode(-1)
cur = dummy
for v in arr:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
def linked_list_to_list(head):
result = []
while head:
result.append(head.val)
head = head.next
return result
def partition(head, x):
small = ListNode(-1)
large = ListNode(-1)
p_small, p_large = small, large
cur = head
while cur:
if cur.val < x:
p_small.next = cur
p_small = p_small.next
else:
p_large.next = cur
p_large = p_large.next
cur = cur.next
p_large.next = None
p_small.next = large.next
return small.next
if __name__ == "__main__":
test_cases = [
([1, 4, 3, 2, 5, 2], 3),
([1, 2, 3], 2),
([3, 2, 1], 2),
([], 0),
([1], 0),
]
for arr, x in test_cases:
head = build_linked_list(arr)
new_head = partition(head, x)
print(f"输入: {arr}, x = {x} => 输出: {linked_list_to_list(new_head)}")
这个模板最大的价值在于,你可以随手加print看每个阶段small链和large链的状态。我在本地调试时经常在p_small.next = large.next之前打印small链和large链,亲眼看到“原来大值链还残留着对后续节点的引用”,一下子就理解了为什么要断尾。
4.4 遇到“看不懂答案”时的复盘流程
刷题最忌讳看完题解抄一遍就跑了。我自己的复盘流程,你可以直接照搬:
- 不看题解,自己先想20分钟,哪怕写个错误版本
- 看题解后,合上代码,自己在本子上画一遍指针移动过程
- 画清楚之后,手写代码,不要参考原答案
- 用本地模板跑至少5组边界case,包括空链表和单节点
- 隔一天,再写一遍,看能不能一次通过
这个方法看起来很笨,但效果极好。分割链表这个知识点,我当时就是用这个流程把它固化下来的。现在再看到任何链表重排题,画图、拆接、断尾,已经是肌肉记忆了。
5. 一道分割链表题带出的通用能力
5.1 “拆-接”思维是链表题的总钥匙
你如果刷过很多链表题,会发现它们翻来覆去就是两件事:拆和接。反转链表是拆掉每条next再反向接上;删除倒数第N个节点是拆掉中间一个节点再把两头接上;合并两个有序链表是逐个比较并把节点按顺序接到新链上;排序链表更是把链表拆成两半再归并接回去。
分割链表就是把“拆-接”练到极致的一道题。它不依赖任何数学性质,不需要巧妙的贪心,纯粹看你敢不敢动next指针。很多人在数组上很溜,一到链表就手足无措,本质是脑子里没有“引用指向”的画面感。刷这道题时,我强烈建议你每走一个节点,就在纸上画出当前所有指针的指向,包括那些“残留”的next。画上三遍,你就知道链表题该怎么思考了。
5.2 链表操作和树操作有什么关系
很多刷题攻略会把链表和树放在一个专题里,因为树可以看成“每个节点有多个next指针的链表”。二叉树每个节点有两个next(左孩子、右孩子),遍历、重建、修改树的很多逻辑,和链表里“拆-接”的思维完全同源。
比如把二叉搜索树转成平衡二叉树,本质上也是在“拆”和“接”;判断二叉树是否对称,和判断链表是否回文是一个套路。把链表基本功打牢,再学树会轻松很多。所以你现在刷分割链表,不只是刷一道题,而是在给后面的树专题打基础。
5.3 刷题之后的三个固化动作
第一,抽象出模板。把今天说的16字口诀“准备哨兵、遍历归类、处理断尾、拼接返回”写进你自己的刷题笔记里。下次看到新题,先套模板,再根据具体规则调整。
第二,用第二门语言重写一遍。我刷力扣的时候,不少语言我都会,所以做完题会分别用Python和C++各写一遍。这不是为了炫技,而是两种语言对“引用”和“指针”的表达方式不同,能逼你从两个角度理解同一个操作。
第三,有意识地做专题梳理。很多刷题攻略会建议按专题刷,比如字符串题先做最长公共前缀,链表题先做反转、合并、分割,树题先做遍历。这样刷的好处是:同类题的套路会连成线。分割链表做完,紧接着刷奇偶链表和按长度分割链表,你才会发现它们是一个模子。
关于这道力扣分割链表,我能分享的核心经验就这些。最后说一句:链表题的bug,十有八九是某个next指向了不该指的地方。写完之后,永远多问自己一句“最后一个节点的next是None吗”。这个习惯,能帮你省下大量调试时间。
