☰
单链表拆分全攻略:从原地拆到带头节点,一篇讲透核心考点
2026/10/16 3:36:35 网站建设 项目流程

单链表拆分的题目,在数据结构作业和面试手写题里出现的频率都不低,但很多同学做到这类题时,还是容易卡在“怎么把节点断开再拼到新链上”这一步。实际上,单链表拆分的核心就两件事:第一,搞清楚按什么规则拆;第二,保证拆的过程中不要丢节点、不要成环。只要把这两个点想透了,代码写起来就是顺理成章的事。

这篇文章就围绕“单链表的拆分例题”来展开,从题目本质、常见拆分思路、带头节点与不带头节点的差异,到实操代码和常见坑位,一次性梳理清楚。无论你是正在准备数据结构考试,还是在复习链表相关的手写算法题,这篇文章都可以作为一份可以直接照做的复习笔记。

1. 单链表拆分的本质:到底在拆什么

1.1 拆分的核心逻辑与题目常见问法

链表拆分,本质上就是把一条链表上的节点,按照某个规则重新分配到两条或更多条链表上。最常见的问法有两种:

  • 按位置拆分:把第1、3、5...个节点放到A链表,第2、4、6...个节点放到B链表,也就是奇偶位置拆分。
  • 按值域拆分:把所有满足某个条件的节点放到A链表,其余放到B链表,比如“小于某个阈值的放一边,大于等于的放另一边”。

这两种拆分方式看起来差别很大,实际代码逻辑高度相似,核心都是遍历原链表,逐个把节点摘下来,挂到目标链表的尾部。关键点在于:每次摘节点时,要先把下一个节点的指针保存下来,否则一旦把当前节点从原链路上摘走,后面的节点就找不到了,链表当场就断了。

我见过不少初学者写拆分代码时,出现“只拆了一个节点,后面的节点全部丢光”的情况,原因就是没有保存后继指针。这个问题在后面演示代码的时候会专门再强调一次。

1.2 为什么拆分题这么常见:考点拆解

链表拆分之所以成为高频考题,是因为它一个题目同时考察了好几个基本功:

第一,考察对链表指针结构的理解。链表节点和节点之间是靠指针串起来的,拆分操作涉及大量指针修改,写错一个就可能导致整个链表断裂或者成环。

第二,考察遍历和摘节点的基本功。拆分要边遍历边摘节点,这比单纯的遍历要难一些,因为遍历过程中链表的形态在不断变化。

第三,考察细节处理能力。比如最后一条链表的尾节点有没有置空、链表原本是空表怎么办、头节点处理是否正确等,这些细节恰恰是评分的关键点。

所以,拆分题其实是一道“一题多考”的综合性题目,会做拆分题,基本上链表这块的核心操作就过了一大半。

2. 拆分题的两种主流思路:原地拆分 vs 新建链表

2.1 思路一:原地拆分,不额外申请节点

所谓原地拆分,就是直接复用原链表上的节点,把这些节点通过修改指针,重新串成两条新链表。这个思路的优势很明显:不需要额外的内存空间,时间复杂度是O(n),空间复杂度是O(1)。

很多教材和考试题默认要求这种写法,因为这是最省资源的做法。写的时候要注意,虽然它叫“原地拆分”,但你仍然需要两条新链表的头指针,以及各自的尾指针,用来挂接新节点。

我用一个生活化的例子来解释:你有一串珠子,现在要把这串珠子分成两串,一串放红色珠子,一串放蓝色珠子。原地拆分的意思就是,你不去造新的珠子,只是把原来串好的线剪开重新穿,红色珠子串到一根新线上,蓝色珠子串到另一根新线上。整个过程珠子还是那些珠子,只是线的连接关系变了。

2.2 思路二:新建链表,复制节点数据

另一种思路是遍历原链表,每遇到一个符合条件的节点,就新建一个节点,把原节点的数据复制过去。这种写法更直观,但空间复杂度变成O(n),而且代码更繁琐。

实际用途上,这种写法多见于你“不想破坏原链表结构”的场景,比如你要保留原始数据做多次拆分,或者原链表有其他指针指向它、不能改动。但在考试和面试中,一般不推荐优先选这个方案,因为多申请了O(n)的空间,还会被追问“能优化吗”。

这里还是用珠子的例子:新建链表拆分,相当于你按照原来珠子的颜色,去商店买了一串新珠子串好,原来那串珠子一动不动。这样最安全,但也最费钱。

2.3 为什么推荐原地拆分:空间与可扩展性考虑

从工程实践角度看,原地拆分的优势不只是省空间。很多时候,链表中的节点除了数据域和指针域,还可能携带其他信息,比如缓存频率计数、引用计数等。如果你新建节点,这些额外的信息都得手动复制,一旦遗漏,新链表的数据就是不完整的。而原地拆分直接将原节点挪到新链上,所有附加信息天然保留。

所以,不管是做题还是实际开发,优先掌握原地拆分,都是更划算的选择。

3. 核心实操:带头节点与不带头节点的处理差异

3.1 带头节点的链表拆分:尾插法的标准写法

先明确一下,很多教材里的链表都带有一个不存储实际数据的头节点,这个头节点存在的意义,是让“在链表第一个位置插入节点”和“在其他位置插入节点”的操作逻辑统一。拆分场景下,头节点也让代码写起来更省心,因为你不需要单独讨论“新链表是不是空表”的情况。

带头节点的情况下,两条新链表各自先分配一个头节点,然后遍历原链表,把满足条件的节点依次尾插到对应链表的末尾。尾插法有一句口诀:先接尾,再移尾。也就是先把新节点的地址赋给当前尾节点的next指针,再把尾指针移动到新节点上。

下面是一个按奇偶位置拆分的典型代码,把带头节点的链表A拆成A和B两条链表,A保留原链表顺序,B是逆序。这个是教材里的常见变形题:

typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; LinkList splitByPosition(LinkList &A) { LinkList B = (LinkList)malloc(sizeof(LNode)); B->next = NULL; LinkList ra = A; // 指向A链表的尾节点 LinkList p = A->next; // 遍历指针,从第一个数据节点开始 while (p != NULL) { ra->next = p; // 把当前节点接到A的尾部 ra = p; // ra后移 p = p->next; // 暂存并后移遍历指针 if (p != NULL) { LinkList q = p->next; // 保存后继,防止断开后丢失 p->next = B->next; // 头插法把节点插入B链表头部 B->next = p; p = q; // 继续遍历 } } ra->next = NULL; // 最后把A链表的尾节点置空 return B; }

这个代码里的关键点在于,奇数位置的节点尾插到A,偶数位置的节点头插到B,所以B链表中的顺序和原链表是相反的。代码里每次拿到偶数位置节点p时,先用q保存了p的后继,再把p头插到B中,最后p跳到q继续处理。如果不保存q,p->next被改成B的头节点之后,后续节点就找不到了。

3.2 不带头节点的链表拆分:首节点处理的坑

不带头节点的情况下,两条新链表初始都是空表,没有任何“哨兵”节点。此时如何判断新链表是不是空表,就成了一个必须处理的分支逻辑。

不带头节点的拆分代码如下:

LinkList splitWithoutHead(LinkList A, int x) { LinkList B = NULL; LinkList ra = NULL, rb = NULL; LinkList p = A; while (p != NULL) { LinkList q = p->next; // 先保存后继 if (p->data < x) { // 尾插到A if (ra == NULL) { A = p; ra = p; } else { ra->next = p; ra = p; } } else { // 尾插到B if (rb == NULL) { B = p; rb = p; } else { rb->next = p; rb = p; } } p = q; } if (ra != NULL) ra->next = NULL; if (rb != NULL) rb->next = NULL; return B; }

可以看到,和不带头节点的写法相比,每次尾插前都要判断一下尾指针是否为NULL。这个判断本身不复杂,但初学者特别容易漏掉。漏掉之后就会出现访问空指针的错误,或者新链表的第一、第二个节点之间没连上。

注意:如果题目要求不能改动A链表的头指针,你可以额外定义一个A2指针指向原链表首节点,或者让函数返回两条新链表的头指针,避免修改原指针变量。

3.3 拆分成两条以上链表:扩展思路

有些题目会要求把一条链表拆成三条甚至更多条,比如按数据分到三个区间。思路是完全一致的,只是需要维护更多的头指针和尾指针。每轮循环里,判断当前节点应该归属哪条链表,然后执行对应的尾插操作。

这里可以提速的地方是:当你有多个尾指针时,可以用一个数组来存储,这样代码里就不需要写一堆if-else,而是通过计算下标来决定挂到哪个链表。比如三条链表分别是[0, 10)、[10, 20)、[20, +∞),那就可以算出区间编号,然后统一走一段追加逻辑。

4. 完整实操:从一个典型题到可运行代码

4.1 题目描述与现场分析

假设我现在拿到这样一道练习题:

设计一个算法,将一个带头节点的单链表A分解为两个带头节点的单链表A和B,使得A链表中只含原表中序号为奇数的节点,B链表中只含原表中序号为偶数的节点,且保持原来的相对顺序。

分析这道题,有几个关键点要抓住:

  • 原表A带头节点,所以实际数据节点从A->next开始。
  • “保持原来的相对顺序”,说明不能用头插法,因为头插法会导致逆序。A和B都必须用尾插法。
  • 由于是奇偶拆分,遍历时可以用一个计数器记录当前是第几个节点,也可以直接利用循环的布尔状态来区分奇偶位置,后者代码更简洁。

4.2 代码实现:保序拆分的完整写法

下面是我写的完整实现,使用尾插法同时构建A和B,保持相对顺序:

LinkList splitKeepOrder(LinkList &A) { LinkList B = (LinkList)malloc(sizeof(LNode)); B->next = NULL; LinkList ra = A; // A 的尾指针,初始指向头节点 LinkList rb = B; // B 的尾指针,初始指向头节点 LinkList p = A->next; // 从第一个数据节点开始遍历 int cnt = 0; // 先把A的头节点next置空,避免后续操作残留旧链 A->next = NULL; while (p != NULL) { LinkList q = p->next; // 先保存后继节点 cnt++; if (cnt % 2 == 1) { // 奇数位置:尾插到A ra->next = p; ra = p; } else { // 偶数位置:尾插到B rb->next = p; rb = p; } p = q; // 移动到原链表的下一个节点 } ra->next = NULL; rb->next = NULL; return B; }

这段代码里,我最开始把A->next置空,这一步很多人会忽略。它的作用是防止原链表的头节点还指向旧的首节点,避免后续链表拼接时出现环。当然,如果你在遍历的过程中已经通过ra把所有数据节点重新串起来了,这一步影响不大,但写上是更稳妥的习惯。

代码中的ra和rb分别充当A和B的尾指针,它们的作用就是让尾插操作的时间复杂度保持在O(1),而不是每次插入都从头遍历到尾。利用计数器cnt的奇偶性来决定挂到哪条链,这样写的好处是逻辑清晰,也不怕题目改成“序号能被3整除的放A”之类的变体。

4.3 无头节点版本的代码对照

如果题目给的是不带头节点的链表,代码会稍微多几个分支。这里给一个完整的无头节点保序拆分版本,方便你对照着看差异。

void splitNoHead(LinkList A, LinkList *AOut, LinkList *BOut) { LinkList ra = NULL, rb = NULL; LinkList p = A; int cnt = 0; while (p != NULL) { LinkList q = p->next; cnt++; if (cnt % 2 == 1) { if (ra == NULL) { *AOut = p; // 第一个节点,直接作为新链头 ra = p; } else { ra->next = p; ra = p; } } else { if (rb == NULL) { *BOut = p; rb = p; } else { rb->next = p; rb = p; } } p = q; } if (ra != NULL) ra->next = NULL; if (rb != NULL) rb->next = NULL; }

对比一下能发现,无头节点版本的核心逻辑和有头节点版本完全一样,区别只在“第一个节点怎么处理”上。有头节点时,头节点天然充当了哨兵,尾插逻辑不用判断链表是否为空;无头节点时,第一次插入需要把新链表头指针直接指向当前节点,后续插入才走尾插。

4.4 测试与结果验证

写完代码,验证是必不可少的。我习惯用一组简单的数据来跑一遍,检查两个关键点:节点是否全部被分配且没有丢失、A和B链表的顺序是否符合预期。

假设原链表数据依次是 1 → 2 → 3 → 4 → 5。

跑完上面的拆分逻辑后,A链表应该是 1 → 3 → 5,B链表应该是 2 → 4。

手动推一遍,至少有四个地方需要核对:

  • 节点5有没有被正确处理,因为它是最后一个奇数位置节点,最容易在循环结束时被丢弃。
  • 节点4被摘走后,节点5是否还找得到,这考验的正是“先保存后继”的习惯。
  • A链表和B链表尾节点的next是否都置为NULL,否则输出链表时会越界。
  • 原链表是否已经变成A链表,也就是A的头节点是否直接指向1。

这四点只要有一处出错,程序跑起来就会出现死循环、段错误或者输出顺序不对。

5. 拆分过程中的常见错误与排查技巧实录

5.1 典型错误一:断链导致后半个链表丢失

这是一个非常典型的错误写法:

while (p != NULL) { ra->next = p; ra = p; p = p->next; // 错误:此时p->next可能已被修改 ... }

问题出在,把p接到ra->next后,如果接下来还要对p->next做修改,那原来的p->next就已经被覆盖了。就算当前没有立刻修改,一旦后续代码执行了p->next = ...,原链表后续节点就全丢了。

解决的办法非常简单:进入循环体时,立刻执行LinkList q = p->next;保存后继,后面所有对p的指针操作都随意,遍历时只用q来推进。

5.2 典型错误二:成环导致死循环

还有一种容易出现的错误,是在拆分的最后忘记把尾节点的next置为NULL,导致新链表的最后一个节点还指着原链表后面的节点。最严重的情况是,如果那个节点又是新链表里的某个节点,链表就形成了环,遍历会无限循环。

排查这种问题有一个笨办法但很有效:写一个打印链表长度的函数,如果长度超过预期就说明有环;或者干脆在关键位置设置断点,逐步查看next指针的指向。

5.3 典型错误三:头插与尾插混用导致顺序反转

有些题目要求保持原有相对顺序,但你用头插法实现,结果顺序反转,整个输出就不对了。这是逻辑层面的错误,不是指针层面的,不太容易通过调试发现,因为链表确实“拆分”好了,只是顺序反了。

这里我的经验是:拿到题先圈出“保持相对顺序”或者“逆序输出”这类字眼,再决定用尾插还是头插。如果题目没有明确说逆序,一般默认保持顺序,用尾插。

5.4 典型错误四:空链表或单节点链表的边界处理

空链表处理起来其实很简单,循环根本不进入,两个链表最终都是空表,只要保证头节点的next置空即可。但单节点链表经常有人出错,因为偶数位置的节点不存在,B链表应该为空,但代码里如果尾插逻辑写得不好,可能出现B头节点指针异常或尾节点未置空的情况。

我的建议是:写完代码后,至少手动测试三种输入——空链表、只有一个节点、只有两个节点。三种边界都能跑通,基本就不会出问题了。

5.5 快速排查清单

为了让你在调试时有章可循,我把拆分类题目最容易出问题的点整理成了一份排查清单:

检查项预期结果出错常见症状
进入循环是否保存后继节点每个节点都能被遍历到后半段节点丢失
A、B尾节点的next是否置空last->next == NULL打印链表时死循环
奇偶位置计数是否从1开始第一个节点归A奇偶位置错位
原链表头节点是否被重置A->next指向第一个分配的节点输出A时出现残留节点
空链表和单节点链表是否单独验证输出不越界、不死循环段错误、崩溃

6. 拆分题目的延伸与变式分析

6.1 从拆分到合并:反向操作的联动理解

拆分和合并经常成对出现。如果你会拆分,那么合并其实就是拆分的逆操作——把两条链表按某种规则交错或按顺序拼回一条链表。

理解了这一点,你就能把两个知识点统一起来。其实它们共用同一套“摘节点、挂节点、移动指针”的方法论。学会一个,另一个自然就能上手。

6.2 按值拆分的应用场景

除了位置拆分,按值拆分在实际中也更常用。比如在一组按时间排序的日志链表里,把耗时超过阈值的请求拆出来单独分析;或者在一个排队链表里,把VIP用户和非VIP用户拆成两个队列。

按值拆分的代码和按位置拆分几乎一样,差别只是判断条件从cnt % 2变成了对数据的比较。前面第3.2小节的代码示例就是一个按值拆分的实现,拆分的阈值是x。

6.3 循环链表的拆分

如果题目给的是一条循环链表,拆分时有一个额外的注意事项:原来的尾节点的next是指向头节点的,在遍历时需要用一个计数器或者判断条件来确定链表是否遍历完,不能简单地判断p是否为NULL。

拆分完成后,两条新链表如果要求也保持循环结构,那么需要把两条新链表的尾节点分别指向各自头节点;如果要求拆成普通链表,就把尾节点的next置NULL即可。

这道题的变形,我在实际做题中遇到的频率不低,重点考察的是对循环链表边界条件的理解。

6.4 双链表的拆分

双链表拆分比单链表复杂一点,因为除了next指针,还有prior指针需要重新连接。核心思路不变,但每拆一个节点,都要同时修改两条链上对应的prior指针,而且要保证不会出现指针悬空或成环。

双链表拆分题目一般出现在课程设计的综合题里,面试手写反而少见。如果你单链表拆分能写得很熟练,双链表无非是多处理一个指针域,不用怕。

7. 拆分类题目的通用解题模板

把各种拆分变体做多了以后,我总结了一个通用的模板步骤,基本上可以套用到绝大多数拆分类题目中。虽然不能覆盖所有极端情况,但作为解题的起点足够用。

第一步,确定边界与结构。确认链表是否带头节点、是否循环链表、拆分后是否要保持相对顺序、是否需要释放原头节点。

第二步,准备新链表骨架。如果有头节点,则分配头节点并置next为空;如果没有头节点,则新链表初始为NULL,同时准备各个尾指针。

第三步,遍历原链表的每个数据节点。进入循环体时,第一行代码永远是保存当前节点的后继指针q,这是整个模板中最关键的一行,能避免绝大多数断链问题。

第四步,按条件把节点挂到目标链表。用尾插法保持顺序,用头插法逆序,挂接时更新对应链表的尾指针。

第五步,循环结束后,将所有新链表的尾节点next置NULL,返回需要的头指针。

第五步虽然简单,但特别容易被忽略。因为尾节点置空属于“最后一步细节”,在写代码时容易被前面复杂的指针操作分散注意力。

8. 实操过程中的个人体会与补充建议

在做拆分类题目的过程中,我最大的体会是:这类题目并不考什么高深算法,它考的就是你对指针的掌控力。链表拆分写得好的人,往往不只是代码熟练,更重要的是脑子里的“指针图”足够清晰。

我自己有一个习惯,遇到任何链表操作题,都会在草稿纸上先画一遍节点和指针的变化过程。尤其是拆分这种涉及多条链表的题目,画图比空想要快得多,而且能有效避免“代码改来改去不知道哪里错了”的困境。

如果你是在准备考试或者面试,我建议你把这篇文章里的每段代码都在本地运行一遍,然后把带头节点、不带头节点、奇偶拆分、按值拆分这四种情况组合出几道变式题来练手。组合方式可以参考:

  • 带头节点 + 按位置奇偶拆分 + 保持顺序
  • 不带头节点 + 按值大小拆分 + 保持顺序
  • 带头节点 + 按位置奇偶拆分 + B逆序
  • 不带头节点 + 分割成三条链表

每种组合都写一遍,写多了之后你会发现,这类题换汤不换药,核心逻辑全都在那“保存后继、移动尾指针、尾节点置空”三板斧里。

最后再说一个写这类代码时很实用的小技巧:如果你担心指针改来改去把自己绕晕,可以给代码加上适当的注释,把每条链表的“当前状态”注释出来。比如ra是A链表的尾节点,rb是B链表的尾节点,p是原链表的遍历指针。这样过两天再回头看代码,也能一目了然,不至于重构整个逻辑。

拆分题本身不难,但它值得你花时间把它练透,因为它是链表操作里最能体现基本功的题目之一,练好它,很多链表相关的问题都会迎刃而解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询