1. 先搞清楚:合并这个操作到底在解决什么问题
很多人学到双向链表的时候,脑子里已经塞满了单链表的插入、删除、反转,再到双向链表的时候感觉就是“换个指针方向再来一遍”。等到老师讲到“双向链表的合并”这一节,往往就容易滑过去,觉得不就是把两个链表接起来嘛,有什么好讲的。但如果你真的在校招笔试、考研真题里碰到链表合并,或者在实际项目中需要维护一段双向有序列表,就会知道这个操作远没有看上去那么简单。
先说清楚这节课要解决的典型问题:给你两个双向链表,要求把它们合并成一个链表。合并这个动作本身分两种场景:一种是“物理拼接”,也就是La的尾巴连上Lb的头,这种最简单,复杂度O(1),但实际课程里考得少;另一种是“按序合并”,也就是两个链表本身各自有序(比如都按升序),要求合并之后依然有序,这种才是重点,也是数据结构实验报告里最常见的题目。
这里先给新手指个方向:如果你刚学到链表这一章,别急着背代码,先建立两个底层认知。第一,链表操作的本质是“改指针”,不是“搬数据”——合并的整个过程,节点一个都没新建,全是在折腾那些next和prev指针。第二,单向链表合并只需要照顾next指针,双向链表合并则必须同时维护prev和next两个方向,这就是它比单链表麻烦的地方,也是课堂上一大堆人写崩的地方。接下来的内容,我会按“结构准备 -> 合并思路 -> 代码落地 -> 边界排查”这条线,把整个合并过程彻底拆开讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 合并前的准备:双向链表的结构和基础操作
2.1 节点结构:比单链表多了什么
双向链表和单链表的核心区别,就是每个节点多了一个前驱指针。用C语言定义节点结构体,通常写成这样:
c复制typedef struct DNode {
int data; // 数据域,这里以int为例
struct DNode *prior; // 前驱指针,指向直接前驱
struct DNode *next; // 后继指针,指向直接后继
} DNode, *DLinkList;
很多初学者不理解:为什么有了next还要有prior?用一个生活化的例子类比,单链表就像一列单向通行的队伍,每个人只知道自己后面是谁,你要找队伍中间某个人,必须从头一个个问过去;双向链表则像地铁车厢之间的通道,每个车厢既能找到下一节,也能找到上一节,这样无论从哪个位置出发,都能朝两个方向走。代价就是每个节点都多占了一个指针的内存,换来的是查找前驱、反向遍历这类操作从O(n)降到了O(1)。
在实际写代码的时候,我建议你给每个指针都加上注释,标明它是前驱还是后继。这个习惯看着多余,等你调试起合并这种涉及多个指针改动的函数时,就会知道有多救命。
2.2 创建带头节点的双向链表
数据结构这门课里,绝大部分链表都是带头节点的。头节点不存有效数据,它的主要作用是统一空表和非空表的操作逻辑,让插入、删除、合并的代码不用针对“第一个元素节点”特判。如果你用的是不带头节点的版本,合并代码里需要额外处理头指针为空的情况,代码会明显变复杂。
这里给出最常用的尾插法建表代码,也就是每次把新节点挂到链表尾部:
c复制DLinkList CreateList_Tail(int arr[], int n) {
DLinkList L = (DLinkList)malloc(sizeof(DNode));
L->prior = NULL;
L->next = NULL;
DNode *tail = L; // 尾指针,始终指向最后一个节点
for (int i = 0; i < n; i++) {
DNode *s = (DNode*)malloc(sizeof(DNode));
s->data = arr[i];
s->next = NULL;
s->prior = tail; // 新节点的prior指向当前尾节点
tail->next = s; // 尾节点的next指向新节点
tail = s; // 更新尾指针
}
return L;
}
这段代码是后面所有操作的地基。注意两行关键赋值:s->prior = tail 和 tail->next = s,这两步才是真正把新节点“焊”进链表里的操作。很多新手只写tail->next = s,忘了给s->prior赋值,结果链表的前驱指针全是野指针,一打印就乱套。
2.3 验证链表是否正确的打印函数
在动手写合并之前,强烈建议先写一个打印函数,并且正着打一遍、反着打一遍。正序遍历验证next指针,逆序遍历(通过prior指针从尾节点往前)验证prior指针。双向链表最坑的地方就是:你只正着遍历看起来完全正常,一到逆序遍历就段错误,这是因为prior指针根本没维护好。
c复制void PrintList(DLinkList L) {
DNode *p = L->next;
printf("正向: ");
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
// 先找到尾节点,再反向打印
p = L->next;
if (p == NULL) return;
while (p->next != NULL) p = p->next;
printf("反向: ");
while (p != L) { // 注意这里走到头节点就停
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
每次写完链表操作,先跑一遍这个函数,确认双向都正常,再继续下一个操作。我在带学生做实验的时候发现,很多合并代码跑出“莫名死循环”或“输出乱序”,根子其实在建表阶段就已经埋下了。
3. 合并的核心实现:有序合并的完整思路
3.1 合并方案怎么选
假设两个链表La和Lb都是升序排列,要把Lb合并进La,同时保持升序。这里有两种做法,我先说结论:
- 做法一:把Lb的节点逐个摘下来,然后依次插入La的正确位置。这种属于“插入法”,时间复杂度是O(m*n),m和n分别是两个链表的长度,效率偏低。
- 做法二:用两个指针分别指向La和Lb的当前节点,比较指针指向的节点值,谁小就把谁接到结果链表后面,然后对应指针往后走。这种属于“归并法”,只需要O(m+n),是王道数据结构、严蔚敏教材里主推的方案。
课堂考试和实验报告里要写的,基本就是归并法。它的核心思想跟“合并两个有序数组”完全一致,但链表版本不需要额外申请O(m+n)的辅助数组空间,只需要改指针就能完成合并。这种“原地操作、不申请额外空间”的特点,也正是链表合并比数组合并优雅的地方。
插入法之所以不推荐,除了时间复杂度高,还有一个更隐蔽的问题:每插入一个节点都要重新从头查找插入位置,在双向链表上虽然可以双向查找,但总体效率依然不理想。归并法只需要维护一个尾指针,每次比较两个候选节点,谁小谁先接入,操作非常直接。
3.2 归并法合并的代码实现
下面给出完整代码。这里选择的是“把Lb合并到La中,最终释放Lb的头节点”这种写法,这也是考研真题常见的处理方式。
c复制void MergeList(DLinkList La, DLinkList Lb) {
DNode *pa = La->next; // 指向La的第一个有效节点
DNode *pb = Lb->next; // 指向Lb的第一个有效节点
DNode *tail = La; // tail指向合并后链表的当前尾节点,初始为头节点
while (pa != NULL && pb != NULL) {
if (pa->data <= pb->data) {
// pa节点接到tail后面
tail->next = pa;
pa->prior = tail;
tail = pa;
pa = pa->next;
} else {
// pb节点接到tail后面
tail->next = pb;
pb->prior = tail;
tail = pb;
pb = pb->next;
}
}
// 把剩余部分直接接到tail后面
if (pa != NULL) {
tail->next = pa;
pa->prior = tail;
}
if (pb != NULL) {
tail->next = pb;
pb->prior = tail;
}
// 释放Lb的头节点
free(Lb);
}
你仔仔细细看一遍这段代码,会发现整段函数里没有一个malloc,这也正是链表归并的精髓:节点数据不动,动的只有指针指向。原来Lb里的节点,在合并完成后全部“移民”到了La中,Lb只剩一个光杆头节点,最后被free掉。
3.3 逐行拆解合并过程
这一节非常重要,因为我发现很多代码能看懂,但让自己手写一遍就卡住。卡住的核心原因是没想清楚“tail、pa、pb这三个指针在每一步之后各自指向哪里”。
初始状态:tail指向La的头节点,pa指向La的第一个有效节点,pb指向Lb的第一个有效节点。每次比较pa->data和pb->data,假设pa更小,那么就把pa这个节点接到tail后面。这里关键点是:在改变pa->prior之前,必须先记录pa的下一个节点,也就是先把pa = pa->next这一步的“原值”留住,否则一旦tail->next = pa; pa->prior = tail;执行完,pa就丢掉了它原本的后继信息。
我习惯在草稿纸上画三行状态,每处理一个节点就更新一次:
- 第1行:当前tail指向谁
- 第2行:pa指向谁
- 第3行:pb指向谁
画了三个节点之后就很容易看出来,整个合并过程就像两条队伍按顺序“出列”并重新排队,tail是排在最后的那个人,负责接住下一个出列的人。等某条队伍空了,直接把另一条队伍剩下的所有人接到tail后面,操作完成。
细心的读者可能已经注意到,上面代码里while (pa != NULL && pb != NULL)这个循环条件。等循环退出时,只有三种可能:pa空了、pb空了、或者两个都空了。接下来的两个if分别处理“pa没空”和“pb没空”的情况,逻辑上是完备的。这里不需要else,因为两个if完全独立,如果两个都空了,两个if都不执行,直接释放Lb头节点,收工。
4. 边界条件与指针陷阱:最容易翻车的地方
4.1 空链表合并:大多数人第一版代码就崩在这
很多人在测试环节偷懒,只用两个正常的链表验证一遍就当作完成了。但老师批改实验报告或者面试官追问的时候,往往先问的就是“如果其中一个链表是空的,你的代码还能跑吗”。
拿上面的归并代码来说,如果La为空,也就是La->next等于NULL,那么pa的初值就是NULL,while循环压根不会进入,代码直接跳到if (pa != NULL)判断。pa是NULL所以不执行,pb非空,于是tail->next = pb; pb->prior = tail;,直接把Lb整体接到La头节点后面。整个流程完全正常。
反过来,如果Lb为空,pb初值为NULL,while不进,if (pb != NULL)不执行,最后只释放Lb头节点,一切正常。这说明上面的代码在空表场景下是安全的,但前提是:你必须确保传入的La和Lb都是带头节点的链表。如果你图省事用了不带头节点的链表,那合并前必须专门判断“头指针是否为NULL”,代码会啰嗦很多,也更容易漏判。
4.2 两个链表都只剩一个节点的情况
再测试一个极端场景:La和Lb各自只有一个节点,比如La存[3],Lb存[1]。期望结果是合并成[1,3]。
走一遍代码循环:第一次比较,pa->data是3,pb->data是1,pb更小,所以pb节点接入tail后面,tail变成pb节点位置,pb往后移动变成NULL。此时循环条件pb != NULL不成立,退出循环。然后if (pa != NULL)成立,pa节点(data=3)接到tail后面。最终结果是[1,3],完全正确。
这个案例看着简单,但它检验了一个非常重要的细节:在“pb接入”分支里,tail更新为pb,随后pb后移成NULL,这时候pa还稳稳指着原来的La节点。如果有人在pb后移之前误改了pa,或者把tail和pa搞混,就会丢链表。所有这种“一个指针走完、另一个指针还剩”的情形,本质上都是在考验你对三个指针当前状态的把握。
4.3 内存释放与悬垂指针
合并完成后,Lb的头节点被free掉了,但如果调用者不知道这个动作,还尝试通过Lb访问链表,就会产生悬垂指针,轻则读到垃圾数据,重则崩溃。所以我在合并函数外面一般会加上注释,明确告诉调用方:这个函数会释放第二个链表头节点,合并后请统一使用La,不要再使用Lb。
还有一种常见的考题变体:不释放Lb的头节点,而是把La和Lb合并成一个“循环双向链表”。这种情况下Lb头节点会变成新链表的一部分,释放逻辑完全不一样。我建议你在做实验时先明确题目到底要哪种,再看手头的代码能不能套用。
关于内存,还有一点格外重要:整个合并过程没申请新节点,所以不需要逐节点free那些“被移动”的节点——它们是正常进入新链表了。但如果你写的合并逻辑里某个节点被“跳过”了,既没接入新链表,又没被释放,那就是内存泄漏。检查这种问题最土也最有效的方法,就是合并后打印一遍链表长度,看是否等于两个原链表长度之和。
5. 实操验证与常见问题排查
5.1 完整的测试代码
光讲思路不跑代码等于白学。下面给出一段可以直接复制运行的完整测试程序,你可以把它跑起来观察合并前后的正反向输出。
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct DNode {
int data;
struct DNode *prior;
struct DNode *next;
} DNode, *DLinkList;
DLinkList CreateList_Tail(int arr[], int n) {
DLinkList L = (DLinkList)malloc(sizeof(DNode));
L->prior = NULL;
L->next = NULL;
DNode *tail = L;
for (int i = 0; i < n; i++) {
DNode *s = (DNode*)malloc(sizeof(DNode));
s->data = arr[i];
s->next = NULL;
s->prior = tail;
tail->next = s;
tail = s;
}
return L;
}
void PrintList(DLinkList L) {
DNode *p = L->next;
printf("正向: ");
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
p = L->next;
if (p == NULL) return;
while (p->next != NULL) p = p->next;
printf("反向: ");
while (p != L) {
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
void MergeList(DLinkList La, DLinkList Lb) {
DNode *pa = La->next;
DNode *pb = Lb->next;
DNode *tail = La;
while (pa != NULL && pb != NULL) {
if (pa->data <= pb->data) {
tail->next = pa;
pa->prior = tail;
tail = pa;
pa = pa->next;
} else {
tail->next = pb;
pb->prior = tail;
tail = pb;
pb = pb->next;
}
}
if (pa != NULL) {
tail->next = pa;
pa->prior = tail;
}
if (pb != NULL) {
tail->next = pb;
pb->prior = tail;
}
free(Lb);
}
int main() {
int a[] = {1, 5, 7, 9};
int b[] = {2, 3, 6, 8, 10};
DLinkList La = CreateList_Tail(a, 4);
DLinkList Lb = CreateList_Tail(b, 5);
printf("合并前:\n");
PrintList(La);
PrintList(Lb);
MergeList(La, Lb);
printf("合并后:\n");
PrintList(La);
return 0;
}
如果你把这段代码跑起来,输出应该是:
text复制合并前:
正向: 1 5 7 9
反向: 9 7 5 1
正向: 2 3 6 8 10
反向: 10 8 6 3 2
合并后:
正向: 1 2 3 5 6 7 8 9 10
反向: 10 9 8 7 6 5 3 2 1
注意合并后反向打印的结果,这个细节非常能说明问题:如果合并过程中prior指针维护错了,反向序列就会乱掉。这也是我为什么坚持要求合并后必须做双向验证的原因。
5.2 高频错误速查表
根据我改实验报告的经验,双向链表合并最常出现的错误基本集中在下面这几类。我整理成表格,你可以照着自查:
| 错误现象 | 根本原因 | 排查方法 |
|---|---|---|
| 合并后正向输出死循环 | 某个节点的next没有置为NULL,或两个节点互相指向 | 在关键步骤处打印当前指针,检查是否存在环 |
| 反向输出乱序 | prior指针在合并分支中漏更新 | 合并后单独逆序遍历,看是否与正序倒过来一致 |
| 报段错误(Segmentation Fault) | 指针未初始化就使用,或访问已被释放的节点 | 用调试器定位崩溃行,检查该行所有指针赋值 |
| 合并后链表长度不对 | 部分节点因指针覆盖而丢失 | 合并前记录长度,合并后重新遍历计数对比 |
| 输出结果不符合升序 | 比较条件写反,或用了大于号 | 复查if分支的条件,确定“谁小谁先接” |
5.3 我自己踩过的一个坑
讲一个真实经历。早期我写这个合并的时候,自作聪明地想把代码精简一点,在循环里把“tail = pa; pa = pa->next;”这几行合并成一行,结果偷懒偷出个很难查的bug——因为C语言的赋值顺序是从右往左,我写成了pa = tail->next = pa这种鬼样子,表面上看是把pa接到了tail后面,实际上tail被先更新,pa的后续节点信息在更新tail之后才被读,导致节点顺序完全错乱。这个问题花了我一个晚上才定位到。
从那以后我给自己立了个规矩:链表指针操作,一律一行只做一个赋值动作。代码多几行没关系,每一步都清清楚楚,调试的时候也不用靠猜。这个习惯在我后来处理更复杂的树、图相关代码时,帮了大忙。
6. 合并之后的延伸思考
6.1 双向链表合并和顺序表合并的差异
如果你已经学过顺序表,回头对比会很有收获。顺序表的归并,往往需要开一个新数组,然后把两个有序数组的元素一个个填进去,空间O(n+m)。双向链表归并则完全在原链表上操作,空间O(1)。但链表归并有它的代价:指针操作更复杂,而且每个节点还要多占一个prior指针的内存。所以现实中,如果你要频繁按下标访问元素,顺序表更合适;如果你要频繁插入删除、而且数据量又大,链表更合适。这正好呼应了数据结构这门课“没有最好的结构,只有最合适的结构”这个核心思想。
6.2 合并操作在实际场景中的应用
别看双向链表合并好像是考试专属,它在实际工程里的影子到处都是。比如播放器的播放列表管理,用户把两个歌单合并成一个新歌单,底层可能就是双向链表的有序或者按序合并;比如文本编辑器的undo/redo历史记录,也用到了双向链表,当你把两份编辑历史拼接起来的时候,本质上就是链表合并操作。还有Redis这种性能敏感的系统,内部维护有序列表结构时,同样会用到类似的双向链表节点重组思路。
如果你以后接触的是高层次语言,比如Python的collections.deque或者Java的LinkedList,这些封装好的双向链表已经帮你屏蔽了底层指针操作。但理解指针层的合并逻辑依然重要,因为这种“通过调整引用关系完成数据重组”的思路,在树、图、跳表等复杂数据结构里会反复出现。
我个人在实际教学和写代码中的体会是:双向链表的合并,看似只是几十行代码,但它真正训练的是“同时追踪多个指针状态”的能力。这种能力一旦建立,你后面学到二叉树的各种旋转、图的邻接表操作,都会顺畅很多。建议你学完这节后,自己试着把题目改一改:比如改成两个链表变成递减有序再合并、或者把合并条件改成自定义比较函数,练上两三遍,才算真正吃透。
