1. 从题目说起:分割链表到底在考什么
力扣上有一类链表题,看着不难,但真正动起手来,十个有八个会掉进同一个坑里。“分割链表”就是其中很有代表性的一道。
这道题在力扣里对应的是第 86 题 Partition List,中文站一般叫“分隔链表”,但很多人也习惯叫它“分割链表”,因为它的核心动作就是把一条链表按某个值切成两段。题目要求其实非常朴素:给你一个链表的头节点 head 和一个特定值 x,把链表中所有小于 x 的节点都放到大于等于 x 的节点之前,同时保持每一部分节点原本的相对顺序不变。
举个例子,链表是 1 -> 4 -> 3 -> 2 -> 5 -> 2,x 等于 3,那么分割后的结果应该是 1 -> 2 -> 2 -> 4 -> 3 -> 5。小于 3 的节点是 1、2、2,大于等于 3 的节点是 4、3、5,两部分内部都是按照原链表中的先后顺序排的,没有做任何排序。
这道题适合谁呢?我觉得有三类人值得认真做一遍:第一类,刚开始刷链表题的新手,它能帮你彻底理解“虚拟头节点”这个高频技巧;第二类,准备面试的候选人,因为面试官特别喜欢在链表快排、链表归并这类问题上追问它的变形;第三类,对链表操作已经比较熟练、但想系统性梳理“链表指针修改顺序”的老手,这道题虽然代码短,但坑点密度确实高。
单看代码量,完整解法不超过二十行,但它的信息量其实很大:涉及链表遍历、指针重接、尾节点置空、稳定性保证,这些点单独拎出来都不难,合在一起就容易翻车。关键词“链表遍历”和“力扣热题100”出现在它附近不是偶然的,这题在热题100里也算常客,值得花时间吃透。
1.1 题目描述与输入输出
先把题目原样拆开看。给定一个链表的头节点 head 和一个值 x,请你将链表分隔,让所有小于 x 的节点都出现在大于或等于 x 的节点之前。要求保留每个分区内节点的原始相对顺序。
输入输出形式是这样的:
text复制输入:head = [1,4,3,2,5,2], x = 3
输出:[1,2,2,4,3,5]
再补几个典型的边界例子,免得后面遇到情况反应不过来:
text复制输入:head = [2,1], x = 2
输出:[1,2]
输入:head = [], x = 0
输出:[]
注意,题目里 x 并不一定出现在链表中,比如 x = 0,链表里可能全是大于 0 的值,那所有节点都会落在“大于等于 x”这一侧;反过来如果 x 比链表中所有值都大,那所有节点都会落在“小于 x”这一侧。这两种情况算法都要能正确处理。
看到这里你应该已经明白了:这题本质上就是一个“按值分区”的问题,和“奇偶链表”那种按位置分区的题有相似之处,但判断条件从“下标奇偶”换成了“值是否小于 x”。
1.2 这道题适合谁,值不值得做
我个人的看法是,这道题的价值被很多人低估了。它看起来简单,但考察的点非常集中,而且每个点都是面试高频。
先说新手视角:很多刚刷链表题的人对“虚拟头节点”是有心理障碍的。什么时候需要用虚拟头节点?凭什么 dummy 能减少边界判断?为什么有些题用了 dummy 代码立刻清爽很多?这道题就是回答这些问题的绝佳样本。你不需要像做链表反转那样死记硬背三指针,也不需要像做链表删除那样纠结删除头节点的特殊情况,你只需要理解“人为造一个不参与业务逻辑的假头,让所有真实节点都变成中间节点”,链表的边界问题就瞬间消解了。
再看面试视角:面试官如果让你现场写这道题,他大概率不是想看你背代码,而是想看两件事。第一,你能不能主动分析出“不能简单地排序,因为要保持相对顺序”;第二,你在接链表尾部的时候,会不会记得把大值链表的最后一个节点的 next 置为 null。这两个点都不难,但能一次写对的人真的不多。
最后是扩展视角:这道题和快速排序的 partition 有天然联系。如果你以后要处理“链表上的快速排序”,partition 这一步其实就是沿用了这个思路。从这个角度说,它在链表题里算是个“承上启下”的位置。
1.3 关键词拆解:链表、遍历、稳定性
标题里反复出现“链表”“遍历”这些关键词,说明这道题的核心技术点就集中在链表操作上。链表和数组最大的区别在于:数组可以用下标随机访问,链表只能通过指针一个个走下去。所以凡是链表题,遍历几乎是无处不在的基础操作。
但“分割链表”比普通遍历多了一个要求,就是稳定性。稳定性这个词在排序算法里很常见,意思是值相等的元素经过处理之后,相对顺序不变。这道题虽然不是在排序,但它的要求本质上是“分区稳定性”:所有小于 x 的节点保持原来的先后顺序,所有大于等于 x 的节点也保持原来的先后顺序。
为什么要强调这一点?因为如果你刷过类似的数组分区题,比如快排的 partition,你可能习惯用“交换”的思路:找到一个中间位置,左边放小的,右边放大的。但对链表来说,交换节点值虽然可行,却会破坏稳定性,而且链表的随机访问能力很差,交换一次要遍历多次,效率和正确性都不好。所以这道题的天然解法不是交换,而是“拆”和“接”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路:不是排序,是“分流”
我把这道题的核心思路总结成两个字:分流。把小于 x 的节点分到一条链,把大于等于 x 的节点分到另一条链,最后把小链的尾节点接上大链的头节点。
这个思路说起来容易,但很多第一次做这道题的人会走弯路,最常见的弯路就是试图用排序。你拿到 [1,4,3,2,5,2] 和 x=3,第一反应可能是“我能不能把链表排个序,然后从头开始找到第一个大于等于 3 的位置,切开?”这个思路本身没有错,但它违反了题目的要求:题目要求保留原始相对顺序,而排序会彻底打乱顺序。如果数据恰好排好序了,你碰巧对了,但一旦数据乱序,结果就是错的。更关键的是,单链表做排序的最优时间复杂度是 O(n log n),而这题用分流法只需要 O(n),所以排序怎么想都不划算。
2.1 为什么不能是排序
展开来说,排序思路错在哪里。假设链表是 [3,1,2],x=2。如果你把链表排序,得到 [1,2,3],然后从 2 处切开,得到 [1] 和 [2,3],结果看起来没问题。但题目要的是“小于 x 在前,大于等于 x 在后”,并且小于 x 的部分要保持原始顺序。原始顺序里小于 2 的只有 1,所以输出 [1,2,3],你半猜半算碰巧对了。
再看 [2,1],x=2。如果你傻乎乎地排序,得到 [1,2],输出也是 [1,2],又对了。但换个例子 [2,1,3],x=2:原始顺序中小于 2 的是 1,大于等于 2 的是 2 和 3,所以正确答案应该是 [1,2,3],排序后也是 [1,2,3],还是碰巧。真正能暴露问题的是 [1,4,3,2,5,2],x=3:排序后是 [1,2,2,3,4,5],但题目要求保留相对顺序,所以正确答案是 [1,2,2,4,3,5],因为原始顺序里 4 在 3 前面。
看出问题了吗?如果你用排序,遇到值全部落在某一侧或已经相对有序的数据,很难暴露错误,一旦数据变复杂,立刻就会被检查出来。所以正确理解应该是:这不是排序题,是“按值分区”题,分完区之后,每个区内部保持原样。
2.2 双虚拟头节点方案的来龙去脉
既然不能排序,那就只能靠“拆链表”来完成。这里就引出双虚拟头节点的方案了。
什么是虚拟头节点?简单说,就是创建一个值无意义的节点,让它的 next 指向真正的第一个节点。为什么要干这件事?因为链表的头节点有时候很碍事。如果你不用虚拟头节点,当你把第一个节点接到小链或大链上时,你必须判断“小链现在是不是空”,空和非空的处理逻辑是两套,代码会多出很多分支。
而虚拟头节点的存在,让小链和大链一开始就“非空”。小链的尾指针永远有东西可以接,大链的尾指针也永远有东西可以接。等所有节点都分完流之后,维护者只需要关心 dummy 的 next 指向谁,而不需要关心谁才是真正的头节点。
这道题需要两个虚拟头节点,一个管小于 x 的节点,一个管大于等于 x 的节点。为什么是两个?因为你要把一条链拆成两条链,拆完再合并。如果只用一个虚拟头节点,你没法同时保存两条链的头部信息和尾部信息,拼起来的时候会非常别扭。两个虚拟头节点加两个尾指针,是写出简洁代码的最佳方案。
2.3 原理解读:为什么能保持相对顺序
很多人会问:为什么用两个尾指针往后面追加,相对顺序就能保住?这个问题值得说透。
链表的每个节点都指向它的下一个节点。你在遍历原链表时,是从头到尾一个个走过去的,走到的节点顺序天然就是原顺序。当你发现某个节点小于 x,就把它接到小链的末尾;发现某个节点大于等于 x,就把它接到大链的末尾。这个动作有一个关键性质:你永远是把正在访问的当前节点追加到对应链表的尾部,并不是把它插入到某个中间位置。
插入位置一旦是“尾部”,那么被插入节点的相对顺序就只由访问顺序决定。先访问的节点一定排在前面,后访问的节点一定排在后面。所以在一个分区内部,节点的顺序和原链表完全一致。这跟队列的先进先出是一个道理,你先来的就排前面,后来的排后面,只要不插队,顺序就不会乱。
这一点也和数组 partition 的思路形成了鲜明的对比:数组 partition 往往采用交换法,交换会把两个元素的位置对调,导致相对顺序被破坏。而链表的分流法通过“追加到尾部”来避免交换,所以稳定性天然成立。
3. 代码实现:C++ / Python / Java 三种写法
思路清楚了,代码其实只有一套模板,换语言只是语法上的差别。我用 C++、Python、Java 三种语言分别写一版,每版都会带上详细注释,方便你直接拿来用。
3.1 C++ 实现与注释
cpp复制class Solution {
public:
ListNode* partition(ListNode* head, int x) {
// 创建两个虚拟头节点:smallHead 用于收集小于 x 的节点
// largeHead 用于收集大于等于 x 的节点
ListNode smallHead(0), largeHead(0);
ListNode* smallTail = &smallHead;
ListNode* largeTail = &largeHead;
// 遍历原链表
while (head != nullptr) {
if (head->val < x) {
// 小于 x:接到小链尾部
smallTail->next = head;
smallTail = smallTail->next;
} else {
// 大于等于 x:接到大链尾部
largeTail->next = head;
largeTail = largeTail->next;
}
// 移动到原链表的下一节点
head = head->next;
}
// 关键一步:大链尾部必须置空,否则可能出现环
largeTail->next = nullptr;
// 小链尾部接上大链的第一个真实节点
smallTail->next = largeHead.next;
// 返回小链的第一个真实节点
return smallHead.next;
}
};
这里有几个值得注意的细节。第一,我把 smallHead 和 largeHead 声明成栈上的对象,而不是用 new 创建,这样不需要手动释放,也避免了内存泄漏的争议。第二,循环里用的 head = head->next,因为我们在修改 head 指向的节点的 next 之前,原始链表的后续关系其实还保留着。为了保险,你当然也可以先存一个 next 指针,但我实测下来这个写法没问题,因为当前节点在接到小链或大链后,它的 next 在下一轮循环之前不会被再次修改,而 head 已经先一步指向了原来的后继节点。
3.2 Python 实现与注释
python复制class Solution:
def partition(self, head: ListNode, x: int) -> ListNode:
# 创建两个虚拟头节点
small_dummy = ListNode(0)
large_dummy = ListNode(0)
# 两个尾指针
small_tail = small_dummy
large_tail = large_dummy
# 遍历原链表
while head:
if head.val < x:
# 小于 x:接到小链尾部
small_tail.next = head
small_tail = small_tail.next
else:
# 大于等于 x:接到大链尾部
large_tail.next = head
large_tail = large_tail.next
# 移动到下一节点
head = head.next
# 大链尾部置空,防止成环
large_tail.next = None
# 拼接:小链尾部接上大链头部
small_tail.next = large_dummy.next
return small_dummy.next
Python 的写法和 C++ 几乎一样,唯一要注意的是,Python 里没有“指针”这个概念,但你用对象引用来理解是完全一样的。small_dummy 和 large_dummy 都是 ListNode 实例,small_tail 只是一个引用,指向当前链表的尾节点,每次追加节点时,修改引用所指对象的 next 属性。
3.3 Java 实现要点
java复制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;
while (head != null) {
if (head.val < x) {
smallTail.next = head;
smallTail = smallTail.next;
} else {
largeTail.next = head;
largeTail = largeTail.next;
}
head = head.next;
}
largeTail.next = null;
smallTail.next = largeDummy.next;
return smallDummy.next;
}
}
Java 版其实没有什么额外需要强调的,就是创建虚拟头节点时必须用 new,而 C++ 可以不用 new,这是语言差异。面试时如果用的是 Java,注意别在循环里用 for (ListNode cur = head; cur != null; cur = cur.next) 这种写法,因为你在循环体里修改了 cur.next 的指向,如果循环变量更新表达式读取的是 cur.next,就可能会出现意外。最稳妥的方式还是统一的 while 循环,或者像某些简洁写法那样,在循环体最后用一个临时变量保存后继节点。
3.4 复杂度分析,为什么空间是O(1)
时间复杂度和空间复杂度是面试必问题。时间复杂度:O(n),n 为链表节点总数。因为从头到尾只遍历了一遍链表,每个节点只处理常数次。
空间复杂度:O(1)。这里特别说明一下,虽然我们创建了两个“虚拟头节点”,但这两个节点是固定不变的,不随链表长度增长而增加。在 C++ 栈上声明也好,在 Java/Python 里 new 出来也好,都是常数级别的额外内存,所以空间复杂度严格来说是 O(1)。
很多人会误以为“我创建了新链表,空间复杂度就是 O(n)”。其实不对。关键看你是不是为每个原始节点都新建了内存空间。这道题从头到尾没有 new 任何业务节点,只是把原始节点的指针重新连接了一下,所以额外空间是常数。
4. 逐步推演与边界处理
代码写得快,不代表理解得深。这一节我带你完整推演一遍示例数据,再把容易出错的几个边界场景单独拉出来看。
4.1 一步一步模拟 [1,4,3,2,5,2], x=3
假设初始链表为:
text复制1 -> 4 -> 3 -> 2 -> 5 -> 2
x = 3。开始时,smallDummy 和 largeDummy 都是单独的空节点,它们的 next 都指向 null,smallTail 指向 smallDummy,largeTail 指向 largeDummy。
遍历开始:
- 当前节点是 1,1 < 3,属于小链。
smallTail->next = 1,于是 smallDummy 后面跟着 1。smallTail 移到 1。 - 当前节点变成 4,4 >= 3,属于大链。
largeTail->next = 4,于是 largeDummy 后面跟着 4。largeTail 移到 4。 - 当前节点变成 3,3 >= 3,属于大链。
largeTail->next = 3,于是大链变成 4 -> 3。largeTail 移到 3。 - 当前节点变成 2,2 < 3,属于小链。
smallTail->next = 2,于是小链变成 1 -> 2。smallTail 移到 2。 - 当前节点变成 5,5 >= 3,属于大链。
largeTail->next = 5,于是大链变成 4 -> 3 -> 5。largeTail 移到 5。 - 当前节点变成 2,2 < 3,属于小链。
smallTail->next = 2,于是小链变成 1 -> 2 -> 2。smallTail 移到最后的 2。 - 当前节点变成 null,循环结束。
此时两条链分别是:
text复制小链:1 -> 2 -> 2
大链:4 -> 3 -> 5
然后执行关键两步。第一步,largeTail->next = null,确保大链的尾节点 5 后面不再指向任何节点。第二步,smallTail->next = largeDummy.next,让小链的尾节点 2 指向大链的第一个真正节点 4。
最终结果:
text复制1 -> 2 -> 2 -> 4 -> 3 -> 5
这个输出正好符合题目要求。
4.2 边界情况:空链表、单节点、全部小于x等
边界情况在面试里直接决定你代码的完备性,逐个来看。
空链表:head 是 null,while 循环一次都不进,直接执行 largeTail->next = null 和 smallTail->next = largeDummy.next。此时 smallTail 就是 smallDummy,largeDummy.next 是 null,所以 smallTail->next = null,最后返回 smallDummy.next 也就是 null。结果正确。
单节点:假设链表只有一个节点 [5],x = 3。5 >= 3,进入大链。循环结束后,smallTail 仍指向 smallDummy,largeTail 指向 5。largeTail->next = null 让 5 指向 null。smallTail->next = largeDummy.next 让 smallDummy 指向 5。返回 5。结果正确。
全部小于 x:假设链表是 [1,2],x = 3。所有节点都进入小链,大链为空。循环结束后,largeTail 仍指向 largeDummy,大链的 next 是 null。执行 largeTail->next = null,这其实就是把 largeDummy.next 再次置为 null,没问题。smallTail->next = largeDummy.next,smallTail 指向最后一个节点 2,把它的 next 指向 null。结果是小链完整且尾部正确。这里最怕的是忘记 largeTail->next = null,但即便忘了,因为 largeDummy.next 本来就是 null,所以这个案例不会暴露问题,反而会掩盖问题。真正的坑在另一种情况。
全部大于等于 x:假设链表是 [4,5],x = 3。所有节点都进入大链,小链为空。循环结束后,smallTail 仍指向 smallDummy。largeTail->next = null 把 5 的 next 置为 null。smallTail->next = largeDummy.next,smallDummy 指向大链头 4。返回 smallDummy.next,也就是 4。结果正确。
x 值比链表所有值都小或都大:这两种情况本质上就是“全部进入大链”或“全部进入小链”,处理方式和上面一样,代码都能正确收尾。
重复值:比如链表是 [2,2,2],x = 2。所有节点都 >= 2,全部进入大链,输出还是 [2,2,2]。这个例子说明,题目对相等元素没有特殊要求,只要它们全都排在“大于等于”一侧即可。
4.3 最容易出错的坑:尾指针置空
我在标题里特意把“尾指针置空”标成最容易出错的坑,因为太多人在这里翻车了。具体来说,就是 largeTail->next = null 这一行。
为什么必须置空?因为链表节点在原始链表中是有 next 指向的。当节点 3 被接到大链时,它的 next 还指向原来的后继节点 2。而 2 由于小于 x,被接到了小链。此时,大链尾部 5 的 next 本来是 null,但大链中间的节点,比如 3 的 next 仍然是 2。如果你不把大链真正最后一个节点的 next 置空,那么在拼接完成后,小链的尾部会指向大链头部,而大链中某个节点又指回小链中的节点,链表就成环了。
具体到这个例子,大链是 4 -> 3 -> 5。节点 3 的 next 原本指向 2,但后来 3 被接到大链时,我们并没有修改 3 的 next,所以 3 的 next 仍然是 2。2 在小链中,而小链和大链拼接后,5 的 next 为 null。如果不把 largeTail 的 next 置空,最终结果会从 3 那里拐回 2,形成 4 -> 3 -> 2 -> ... 的循环,导致无限遍历。
更隐蔽的情况是:所有节点都小于 x,大链为空,此时 largeTail 就是 largeDummy,largeDummy.next 本身是 null,所以置不置空对这个 case 没有影响。但代码不能靠“碰巧”规避问题,一定要每次都执行置空操作,这是一种防御性习惯。面试官非常看重这个细节,你主动写上这行,说明你真的理解链表操作的危险性。
5. 常见错误与排查技巧
写链表题,最大的敌人不是思路,而是细节。我把平时带人刷题时遇到的典型错误整理成了一张速查表,碰到问题可以对着查。
5.1 常见错误速查表
| 错误类型 | 具体表现 | 根本原因 | 解决方法 |
|---|---|---|---|
| 忘记尾置空 | 输出链表成环,或结果超时 | 没有把大链尾部 next 设为 null | 拼接前必须执行 largeTail->next = null |
| 小链大链拼反 | 输出顺序错乱 | 把小链接到了大链的后面 | 应该是 smallTail->next = largeDummy.next |
| 返回头节点错误 | 结果多了虚拟头节点 | 直接返回了 smallDummy | 返回 smallDummy.next |
| 循环顺序写错 | 链表后半段丢失 | 在修改 next 之后才用 head 取下一节点 | 循环体内先 head = head->next 再处理,或提前保存 next |
| 漏掉 x 相等的情况 | 相等节点跑到小链 | 判断条件写成 <= |
严格按题目写 < x 和 >= x 两个分支 |
| 误用排序思路 | 结果不满足稳定性 | 没有理解“保持相对顺序”的要求 | 改用双虚拟头节点分流方案 |
其中“循环顺序写错”值得展开一下。有些人在处理当前节点时,会先执行 smallTail->next = head,然后顺手写 head = head->next,这时候 head 的 next 可能已经被上一步修改过了吗?其实在当前这个节点上不会,因为当前节点在进入循环之前,它的 next 指向的一定是原始链表的后继节点,你只是把当前节点接到了另一条链上,并没有改变当前节点的 next。所以 head = head->next 依然能拿到原始后继。但如果你在循环里做了更复杂的操作,比如先断开当前节点的 next,再接到目标链上,那就必须先用临时变量保存后继节点。为了避免隐患,很多老手习惯进入循环第一步就保存 ListNode* next = head->next,然后再处理 head,最后让 head = next。这种写法更保险,建议新手养成这个习惯。
5.2 问题定位方法:手画链表、打印节点
如果代码跑出来不对,怎么快速定位?我给你两个方法:第一个是手画链表,第二个是打印节点。
手画链表听起来土,但效率非常高。拿出一张纸,把每个节点的值和指针都画出来,然后模拟代码的每一步。重点观察每次把节点接到新链后,这个节点的 next 变成了什么。很多 bug 是在第 3 次或第 4 次遍历时才出现的,手画会帮你找到“哪一步开始拐错”。
打印节点则更适合在本地调试。在循环里加一行输出,打印当前节点的值和当前节点在内存中的地址,再打印 smallTail 和 largeTail 当前指向的地址。如果发现某个节点地址被同时挂在两条链上,说明这个节点的 next 没断开,往往是成环的征兆。这种问题靠看是看不出来的,但打印之后一目了然。
在力扣的网页编辑器里,有时不方便加打印语句,你可以先在自己的 IDE 里搭一个最简单的链表结构,把测试用例复制进去跑一遍。本地调试自由度更高,也没什么环境限制。
5.3 我踩过的实战教训
我当年第一次做这道题时,看题解觉得就这?结果自己写的时候踩了一个特别蠢的坑:把 largeDummy.next 和 smallDummy.next 搞混,返回了 smallDummy.next 之后,又忘了把大链尾部置空,结果在测试用例 [1,4,3,2,5,2] 上直接超时。那次之后我就学乖了,凡是“把两条链表拼成一条”的题,收尾阶段必须做三件事:
第一,检查有没有哪条链的尾部还残留着原始链表中的后继指针,有就置空。第二,检查拼接方向,永远是“前半部分的尾部”接“后半部分的头部”,别接反。第三,检查返回值,虚拟头节点不能作为结果返回,返回的一定是 dummy.next。
另外还有一个容易忽略的细节:这道题里,小于 x 的节点和大于等于 x 的节点,两个分区的边界非常严格。有人会因为 x 是整数,就把 >= 写成 >,觉得等于 x 的节点放哪边都一样。但题目明确说了,小于 x 的在前,大于等于 x 的在后,等于 x 的属于后半部分。如果你写 >,等于 x 的节点就会被误放到小链尾部,在某些测试用例上会顺序错误。既然题目写了“大于等于”,条件里就必须写 >=。
6. 延伸思考:和链表快排、LeetCode 725 的关系
很多人刷题有个习惯:一题一题地刷,刷完就忘。其实链表题之间关联性很强,把这层关系理清楚,你连刷题顺序都能优化。
这道“分割链表”做完之后,我推荐你马上做两件事:第一,去理解快速排序在链表上怎么实现;第二,去看一眼 LeetCode 725 题“分割链表”。这两件事都能帮你把这个知识点消化得更透。
6.1 从 partition 到快速排序
快速排序的核心是 partition 操作:选定一个基准值,把数组分成小于基准和大于等于基准两部分。数组上的 partition 有很多实现方式,比如挖坑法、双指针法,但链表上实现快排最自然的方式,其实就是这道题的双虚拟头节点思路。
你可以在递归过程中,把链表头节点作为基准值 x,然后遍历剩余节点,把所有小于 x 的节点和小值链表,把大于等于 x 的节点和大值链表,递归对小值链表和大值链表做同样的操作,最后按照“小值链表 + 基准节点 + 大值链表”的顺序拼接起来。这个过程中的 partition 步骤,和今天的题目几乎一模一样。
理解了这一点,你在面试中被问到“怎么对链表进行快速排序”时,就不会慌。你只需要说:我先把链表 partition 成两组,再递归处理,这里的 partition 和力扣 86 题一样,用双虚拟头节点即可。面试官听到这里就知道你真的理解了。
对比一下才能看出这种写法的好处:有些人喜欢在链表上做“交换节点值”的 partition,那是数组思路的直接迁移,写起来容易,但稳定性差,而且交换节点值需要多次遍历。用双虚拟头节点分成两个链表,再拼接回去,代码量少,稳定性也好,是更适合链表的方案。
6.2 LeetCode 725 分割链表:同样中文名,不同的题
这里我要专门提醒一个容易混淆的点。力扣上有一道题叫 Split Linked List in Parts,中文名也叫“分割链表”,题号是 725。它的题目是:把链表分隔成 k 个连续的部分,每部分长度尽量相等,且前面部分的长度不小于后面部分。注意,这里强调的是“切段”,而不是“按值分区”。
所以你在中文社区搜“分割链表”时,可能会同时搜到 86 和 725 两题。86 题的英文名是 Partition List,重点在于按值重新排列节点;725 题的重点在于按长度切分链表。
如果面试官口中说“分割链表”,你需要先确认他到底指哪一题。如果描述里出现了“给定 x”值,那就是 86;如果出现了“分成 k 个部分”,那就是 725。这两题解决思路完全不同,725 题需要先遍历一次求链表长度,计算出每部分的基准长度和余数,再用双指针逐段切断。
6.3 刷题建议:怎么举一反三
按照我自己的刷题习惯,做一道链表题,至少要连带复习三个主题:同类型的题目、相通的技巧、以及常考的变体。
这道题的同类型题目包括:LeetCode 328 奇偶链表、LeetCode 86 分隔链表、LeetCode 725 分割链表。三者的操作手法都很像,区别只在于分组依据:奇偶链表按节点下标分,86 题按节点值分,725 题按固定长度切段。把这三道题放在一起刷,你会快速掌握“链表重连”这一大类题目的套路。
相通的技巧是“虚拟头节点”。只要你发现题目要求把链表拆开重组,大概率可以用虚拟头节点来简化边界判断。这个技巧在链表的插入、删除、合并等场景里出现频率极高,值得专门整理一个专题。
常考的变体有一个很经典:如果把“小于 x 在前”的规则改成“奇数在前,偶数在后”或者“某个特定值移到末尾”,你能不能第一时间想到用同样的思路改条件?我的建议是,每做完一道题,都试着改一改条件,再跑一跑测试,这样你对规则的理解会深刻得多。
我个人在实际操作中的体会是,链表题的代码往往只差关键几行,差别就在你有没有形成一套自己的“收尾检查清单”。分割链表是这套清单的绝佳训练场:双虚拟头解决了头问题,尾置空解决了环问题,拼接顺序解决了方向问题,返回值解决了假头问题。等你把这套流程内化成本能,再去做链表相关的其他题目,会感觉顺畅非常多。
