开头:
做数据结构课程设计或者复习考研的时候,链表那一章几乎每个人都会被“合并”这道坎卡一下。单链表还好说,无非是尾指针接一下;一旦换成双向链表,很多人的代码就开始漏风——最常见的就是合并完发现某个节点的前驱指针指错了,或者遍历的时候节点丢了。今天这篇就把双向链表的合并彻底讲清楚,从最基础的结构定义到两种典型的合并场景都过一遍,代码可以直接抄进实验报告或者期末复习笔记里。
这篇内容适合正在学数据结构的学生、准备考研的党,以及想复习链表底层逻辑的开发者。我没有用工程里花哨的封装,全部以标准C语言实现,因为数据结构课程和考试基本都是这个语境。看完你不仅能写出双向链表的合并,还能理解为什么别人代码里那些看似多余的判断其实是必要的。
1. 内容整体设计与思路拆解
1.1 双向链表为什么容易在合并时翻车
双向链表相比单链表,核心差别就是每个节点多了一个前驱指针prior。这个指针在插入、删除操作中是件好事,因为它让双向遍历变得顺畅;但代价是,我们需要维护的指针从“一条线”变成了“两条线”。
合并的时候尤其明显。单链表合并,你只需要盯着 next 指针把两个链表串起来,逻辑比较直白。而双向链表合并时,每连接一个新节点,不仅要把新节点的 next 指向正确的后继,还要把后继节点的 prior 指回新节点。很多人在第一步就漏掉后半句,或者漏掉首节点的 prior 置空,结果合并完之后链表一反向遍历就出问题。
我自己的学习经验是,处理双向链表任何操作之前,先在纸上把“两个节点之间的指针关系”画出来。合并的复杂度高于单链表,但并没有跳跃性的困难,关键是思路要清晰。
1.2 合并的本质与两种典型场景
“合并”这个词在不同题目里含义不同。按我在实验和刷题中遇到的场景,双向链表合并大致分两类:
- 无序合并:两个链表不做任何排序,直接把其中一个链表整体衔接到另一个链表的尾部。这个需求在真实业务中也很常见,比如把两个日志列表拼接成一个总日志。
- 有序合并:两个链表原本都是递增或递减有序的,希望合并之后的新链表仍然有序。这种类型在考研题和面试题里出现频率更高,它牵扯到节点逐个比较和插入,对循环不变量的理解要求更高。
这两类操作在代码实现上差异很大。无序合并本质上是一个 O(1) 的指针操作(前提是已知尾节点),而有序合并需要逐个遍历两个链表,时间复杂度为 O(n+m)。下面我会各写一个完整的实现,并结合我在调试中遇到的坑来展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 合并操作之前的必备基础
2.1 结构定义与带头结点的选择
先说明一下,链表实现通常有两种风格:带头结点和不带头结点。写实验报告的时候两种都有人用,但我要先给出我的建议:尽量使用带头结点的方式。
带头结点意味着链表的第一个实际数据节点之前,还有一个不存储数据的头结点。它最大的好处是:空表的判断、头插操作、统一插入逻辑都变得非常简单,不需要为“插入位置是首位”单独写分支。合并操作更是如此,特别是有序合并时,带头结点能让“结果链表的起始位置”变成一个固定对象,非常舒服。
结构定义如下:
c复制typedef struct DNode {
int data; // 数据域
struct DNode *prior; // 前驱指针
struct DNode *next; // 后继指针
} DNode, *DLinkList;
这个定义非常常规,笔试和面试基本都长这个样子。data用整型是为了演示方便,换成任意业务类型都可。
我把“带头结点”的实现在这里展开一下:创建空链表时,实际上创建的是一个只有头结点的链表。这个头结点的 data 不使用,prior 和 next 都先置为 NULL。这样后续所有操作都有了一个固定的起点,for 循环出界、while 判断空表都变得可预期。
2.2 建表与打印:合并的测试基础设施
写合并功能之前,必须有建表和打印函数,否则合并结果没法验证。双向链表的打印要写两个版本:正向遍历和反向遍历。反向遍历是验证双向链表“双向性”是否被破坏的关键,这一步我每次调试都不会跳过。
正向打印:
c复制void PrintList(DLinkList L) {
DNode *p = L->next;
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
反向打印:
c复制void PrintListReverse(DLinkList L) {
DNode *p = L;
while (p->next != NULL) {
p = p->next;
}
// 此时 p 指向最后一个节点,开始反向遍历
while (p != L) {
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
为什么反向打印能验证合并是否成功?因为如果合并过程中漏掉了某个 prior 指针的赋值,正向打印可能一切正常(next 链没断),但反向打印走到那个位置就会输出错误值,甚至死循环。这个测试手段非常便宜,但能抓到大多数低级错误。
2.3 尾插法建表:按顺序构造测试数据
为了测试合并效果,我需要一个按用户输入顺序建立链表的函数。这里用尾插法,也就是每次把新节点接到链表尾部:
c复制DLinkList CreateListTail(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 *node = (DNode *)malloc(sizeof(DNode));
node->data = i * 2; // 测试数据,实际可改成 scanf
node->prior = tail;
node->next = NULL;
tail->next = node;
tail = node;
}
return L;
}
这里有一个细节值得强调:尾插法在插入新节点时,node->prior = tail 容易被漏写。漏写的后果,在创建单个链表时可能感觉不出来,但到了反向遍历或后续的合并操作中,就会突然冒出来。上面代码里我把指针连接的顺序写得很明确:先设置新节点的前驱和后继,再更新旧尾部的 next,再移动 tail。如果你先移动了 tail,再设置新节点的 prior,逻辑就乱了。
3. 双向链表合并的完整实现
3.1 方案一:无序合并(整体拼接)
如果你只是想快速把两个链表拼成一个,比如把链表 B 的所有节点接到链表 A 的尾部,那么最优方案是:找到 A 的尾节点,做三个指针操作即可。
这里先给出完整函数:
c复制void ConcatenateList(DLinkList A, DLinkList B) {
if (B->next == NULL) {
return; // B为空链表,无需操作
}
DNode *tailA = A;
while (tailA->next != NULL) {
tailA = tailA->next;
}
DNode *headB = B->next;
tailA->next = headB;
headB->prior = tailA;
// 断开 B 头结点,避免重复管理
B->next = NULL;
}
这段代码的意图很清晰,但我实际测试时发现,很多初学者会在“断开 B 的头结点”这一步犹豫。为什么要断开?因为如果不把 B->next 置为 NULL,原链表 B 的头结点依然会通过 next 指向原来 B 的第一个节点,这时候 A 链表的尾部也通过 next 指过去,等于两个头都引用同一段节点。后面释放内存时,就可能出现双重释放的问题。
另外一个细节:如果 B 是空链表,B->next == NULL,直接返回即可。我不需要做任何拼接。这个判断不是浪费时间,它保证了函数在任何输入下都是安全的。
这个方案的复杂度是 O(n),其中 n 是链表 A 的长度。如果我们在链表结构中额外存储一个 tail 指针,这个操作可以变成 O(1)。但课程设计中通常不会这么设计结构体,所以保持简单即可。
3.2 方案二:有序合并(按元素大小插入)
有序合并是本章的重头戏。它的思路类似归并排序中的 merge 操作:初始化一个结果链表,然后用两个指针分别指向 A 和 B 的第一个数据节点,谁小就把谁往结果链表后面接,然后移动对应指针。
不过这里有一个细节:我最初实现时直接在 A 链表上进行原地穿针引线,结果代码写起来非常繁琐,因为要处理两个链表头部在结果链表中谁先谁后的判断。后来换成了“带头结点+尾插法”的思路,用一个空链表 C 作为结果容器,每次取较小的节点接到 C 的尾部。这样在逻辑上更直观,代码也更不容易错。
代码如下:
c复制DLinkList MergeSortedList(DLinkList A, DLinkList B) {
DLinkList C = (DLinkList)malloc(sizeof(DNode));
C->prior = NULL;
C->next = NULL;
DNode *tailC = C;
DNode *p = A->next;
DNode *q = B->next;
while (p != NULL && q != NULL) {
if (p->data <= q->data) {
tailC->next = p;
p->prior = tailC;
tailC = p;
p = p->next;
} else {
tailC->next = q;
q->prior = tailC;
tailC = q;
q = q->next;
}
}
// 把剩余部分接到 C 后面
if (p != NULL) {
tailC->next = p;
p->prior = tailC;
}
if (q != NULL) {
tailC->next = q;
q->prior = tailC;
}
A->next = NULL;
B->next = NULL;
return C;
}
每移动一个节点,插入到 C 的尾部,这一步的核心就是更新三个指针:C 尾部的 next 指向新节点、新节点的 prior 指向尾部、tailC 后移。有些教程还有一个很常见的说法:“新节点的 prior 不用管,等下次插入再处理”。那不行,因为当前节点已经接在链表中了,它的 prior 必须立刻指向它的前驱,否则中间状态就是非法的。虽然最终可能被下一次操作覆盖,但调试时这种中间态就足以把你绕晕。
循环结束后,必然有一个链表已经走空,另一个还剩一些节点。由于这两个链表自身是有序的,剩余部分直接接到 C 的尾部即可,不需要再比较大小。这里判断条件是 p != NULL 和 q != NULL,两个都判断,防止遗漏。
3.3 复杂度分析与考研常考变式
有序合并的时间复杂度是 O(m+n),空间复杂度是 O(1)。为什么空间是 O(1)?因为我没有新建任何数据节点,只是把原来两个链表的节点重新串了起来。C 这个链表只额外分配了一个头结点,这属于常数空间。
考研题中常见的变式有三种:
- 两个链表是递减有序,要求合并成递增有序。这个直接反转其中一个链表,然后再做归并,或者比较时取较大的值即可。
- 结果链表要求不占用额外空间。那我上面的方案已经实现了,因为你只创建了一个头结点,没有复制任何节点。
- 要求把 B 合并到 A,且最终返回 A 的头指针。这种题目稍微绕一点,本质上是原地归并,但是我建议平时练习时先写“合并到新链表”的版本,再改造成原地版本,因为前者更容易验证正确性。
4. 常见错误与排查技巧实录
4.1 指针更新顺序不对导致节点丢失
这是我调试中遇到最多的一类问题。比如下面这种错误写法:
c复制tailC->next = p;
p = p->next;
p->prior = tailC;
tailC = p;
你别小看这几行顺序的差异,p = p->next 一旦提前执行,后面再去取 p->prior 就已经不是原来那个节点了。正确的做法是:拿到 p 指向的节点,先把它接入 C,再移动 p。在移动 p 之前,p 的 next 还可能指向原链表的后续节点,这正是我们需要在移动后继续访问的,所以不能在接入 C 之前就修改 p 的 next。如果你不小心在拼接时提前改了 p->next,那后面 p 就找不到剩余链表了。
正确的顺序写出来其实就四步:
- tailC->next = p(新节点接入 C)
- p->prior = tailC(新节点回指 C 的尾)
- tailC = p(C 的尾指针后移)
- p = p->next(p 继续遍历原链表)
第2步和第4步很多人会搞混,记住一个原则:只要节点已经接入新链表,它的 prior 必须立刻被更新,否则就丢信息。
4.2 空表与单节点链表的边界测试
代码写完之后,不测边界等于没写。我建议至少构造三组测试:
- 两个链表都为空
- 其中一个链表为空
- 两个链表各只有一个节点
为什么不推荐只测常规数据?因为空表和单节点恰恰最考验 prior 指针和循环终结条件的处理。我在测试中遇到过空表合并后反向遍历死循环的情况,原因就是合并函数里把 C 的 next 设置成了 NULL,但是 C 的 prior 没有维护好。这种情况,只要在测试用例里加入空链表,立刻就能暴露。
一个比较稳妥的测试函数结构如下:
c复制void TestMerge() {
DLinkList A = CreateListTail(5); // 0 2 4 6 8
DLinkList B = CreateListTail(4); // 1 3 5 7
DLinkList C = MergeSortedList(A, B);
printf("正向:");
PrintList(C);
printf("反向:");
PrintListReverse(C);
}
我的经验是,每次调试都先跑一遍“正向”再跑一遍“反向”,两个输出都能对上,再提交代码。尤其是写实验报告的时候,这种细节能给分数带来实质性提升。
4.3 关于 head 结点资源的释放
有些同学会问,合并之后原链表 A 和 B 的头结点怎么处理?如果是合并到新链表 C 的方案,A 和 B 的头结点已经失去了对数据节点的所有权(数据节点全部转移给了 C),这时候可以释放 A 和 B 的头结点,也可以不释放。在课程设计中,不释放一般不影响判分;但在涉及内存泄漏检测的工程环境里,一定要释放:
c复制free(A);
free(B);
不过要注意释放的时机:必须先断开 A 和 B 头结点到数据节点的引用(我在上面的代码里已经处理了 A->next = NULL; B->next = NULL;),否则释放头结点后再通过 A->next 访问数据节点,就会产生悬空指针。
4.4 我在调试时用的一个“笨办法”
如果合并结果不对,我一般不会直接去盯代码,而是先在纸上把合并后的预期链表画出来,然后逐步在代码里加打印,追踪每次插入时的 p 和 q 指向哪个节点。这个办法看起来原始,但胜在有效。
比如下面这种关键位置打印:
c复制printf("[debug] p->data=%d, q->data=%d\n", p->data, q->data);
这段代码不会留在最终版本里,但调试时能帮我一眼看清循环的走向。特别是当两个链表数据域的值有重复时,你能清楚看到 <= 这个判断符的作用。如果你把 <= 改成 <,合并后重复值节点的先后顺序就会变化,这在某些需要稳定排序的场景下是有影响的。
5. 实操过程与核心环节实现
5.1 完整可运行的示例代码
为了让大家能直接跑起来,我把整个测试程序整合成一份,你复制到本地编译器里就能用。我测试用的环境是 Dev-C++ 和 VS Code 的 C/C++ 插件,代码遵循 C99 标准,没有依赖额外库。
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct DNode {
int data;
struct DNode *prior;
struct DNode *next;
} DNode, *DLinkList;
DLinkList CreateListTail(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 *node = (DNode *)malloc(sizeof(DNode));
node->data = i * 2;
node->prior = tail;
node->next = NULL;
tail->next = node;
tail = node;
}
return L;
}
void PrintList(DLinkList L) {
DNode *p = L->next;
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
void PrintListReverse(DLinkList L) {
DNode *p = L;
while (p->next != NULL) {
p = p->next;
}
while (p != L) {
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
void ConcatenateList(DLinkList A, DLinkList B) {
if (B->next == NULL) {
return;
}
DNode *tailA = A;
while (tailA->next != NULL) {
tailA = tailA->next;
}
DNode *headB = B->next;
tailA->next = headB;
headB->prior = tailA;
B->next = NULL;
}
DLinkList MergeSortedList(DLinkList A, DLinkList B) {
DLinkList C = (DLinkList)malloc(sizeof(DNode));
C->prior = NULL;
C->next = NULL;
DNode *tailC = C;
DNode *p = A->next;
DNode *q = B->next;
while (p != NULL && q != NULL) {
if (p->data <= q->data) {
tailC->next = p;
p->prior = tailC;
tailC = p;
p = p->next;
} else {
tailC->next = q;
q->prior = tailC;
tailC = q;
q = q->next;
}
}
if (p != NULL) {
tailC->next = p;
p->prior = tailC;
}
if (q != NULL) {
tailC->next = q;
q->prior = tailC;
}
A->next = NULL;
B->next = NULL;
return C;
}
int main() {
DLinkList A = CreateListTail(5);
DLinkList B = CreateListTail(4);
printf("链表A:");
PrintList(A);
printf("链表B:");
PrintList(B);
DLinkList C = MergeSortedList(A, B);
printf("有序合并结果:");
PrintList(C);
printf("结果反向遍历:");
PrintListReverse(C);
free(A);
free(B);
free(C);
return 0;
}
这段代码跑出来的结果应该是:
code复制链表A:0 2 4 6 8
链表B:0 2 4 6
有序合并结果:0 0 2 2 4 4 6 6 8
结果反向遍历:8 6 6 4 4 2 2 0 0
注意我第二个链表用的也是 i * 2,只是长度少1,所以两个链表会有大量重复值。这样能同时验证 <= 判断下的稳定性。
5.2 每段代码的意图拆解
上面的代码过了编译之后,我希望你逐段理解。
CreateListTail 函数内部用了一个 tail 指针,它始终指向链表最后一个节点。每次循环中,先创建一个新节点,然后通过三行代码连到 tail 后面:
c复制node->prior = tail;
node->next = NULL;
tail->next = node;
tail = node;
这里没有 node->next 的后续处理,因为新节点本身就是最后一个,它的 next 本来就应该是 NULL。如果你忘了给 node->next 赋 NULL,打印时 while 循环的判断条件就会失效,遍历会越过链表边界,属于未定义行为。
MergeSortedList 内部有一个非常关键的设计:我把 C->next 一开始就设置为 NULL。这样即使合并两个空链表,C 也是一个合法的空链表。这个习惯很值得培养,因为“初始化不干净的后果往往要等很久才暴露”是链表调试中最折磨人的坑。
5.3 内存布局视角下的指针连接
如果你对指针连接还是觉得抽象,可以换个视角看:每个节点在内存里是一块连续的区域,data 占用4字节,prior 和 next 各占用8字节(64位系统)。连接两个节点,本质上是把前一个节点的 next 区域写入后一个节点的地址,把后一个节点的 prior 区域写入前一个节点的地址。
合并时我做的所有操作,就是在一组已知地址之间建立新的引用关系。我们可以先忽略原链表中那些不再需要的连接关系,因为只要没有指针能从任何入口访问到它,这段关系就是无效的,不影响正确性。这也是为什么我不会刻意去清理每个节点的原 next 或 prior 遗留值——它们会被新的连接覆盖,或者变得不可达。
6. 双向链表合并的延伸思考
6.1 合并排序、归并排序与基础操作的关系
很多教材会把“有序双向链表合并”作为一个前置技能,放在归并排序和外部排序之前。因为归并排序的核心思想就是把两个有序序列合并成一个更大的有序序列。如果你连双向链表的合并都写不熟练,后面看二路归并排序的代码就会很吃力。
我自己的学习建议是,不要停留在“能从键盘输入两个链表然后合并输出”这个层面。你可以进一步做一个小练习:把归并排序的思路应用到链表上,写一个完全基于链表的归并排序算法,它的时间复杂度是 O(n log n),但是不需要额外开辟数组空间。这才是合并操作真正值钱的地方。
6.2 从课程设计到生产环境:双向链表还够用吗
写完这篇合并代码之后,你可能会有个疑问:实际开发中,真的有人用双向链表吗?说实话,纯手写的双向链表在生产代码中不算多,但它的思想被大量封装在高级语言的标准库里。比如 Java 的 LinkedList 就是双向链表实现的,Redis 的列表对象在元素较多时也会使用双向链表结构。理解了双向链表的合并逻辑,你再去读那些标准库源码中的插入、区间删除、合并方法,就会感觉亲切很多。
另外,工程里做大数据量有序合并,往往不会用链表,而是用跳表、B+树或者外部排序中的败者树。但这不意味着链表白学了——数据结构课程的实验,重点不是让你搞出一个性能达到生产标准的链表,而是让你建立“指针操作”“内存管理”“边界条件”这些底层意识。这些意识在排查内存相关 bug 时,价值完全超过任何一种特定数据结构本身。
6.3 如果面试官让你“原地合并,不要新链表”
我在文章前面提到,有时候面试官会要求不创建新链表,直接在原链表 A 上合并链表 B。这里我简单说下思路。
关键区别在于:你要确定结果链表的头部。由于链表 A 有头结点,最终结果链表的头结点一定还是 A 的头结点,这个位置不会变。然后在循环中重复“比较 A 和 B 的当前节点、把较小的节点接入结果链表尾部”这一过程,但要特别注意“tail”指针的维护。
核心代码片段可以这样写:
c复制DNode *tail = A;
DNode *p = A->next;
DNode *q = B->next;
while (p != NULL && q != NULL) {
if (p->data <= q->data) {
tail->next = p;
p->prior = tail;
tail = p;
p = p->next;
} else {
tail->next = q;
q->prior = tail;
tail = q;
q = q->next;
}
}
这个代码是不是看起来跟前面“合并到新链表 C”很像?确实很像,唯一的区别是我把结果链表的头结点直接固定为 A。因为合并不会破坏 A 的头结点,所以不需要额外分配内存。
剩下两个收尾判断也完全一样。最后把 B->next 置空,释放 B 的头结点即可。这个方法我建议在理解上一版之后再尝试写,如果一开始就直接写这个版本,很容易在 p、q 移动和 tail 更新之间绕晕。
6.4 循环双向链表的合并提示
有些教材里的双向链表是带尾节点成环的,即最后一个节点的 next 指向头结点,头结点的 prior 指向最后一个节点。面对这种结构,合并逻辑会有一点变化:你需要找到 A 的尾节点时,判断条件从 tailA->next != NULL 变成 tailA->next != A。并且合并后,要把尾部节点的 next 重新指向 C 的头结点,同时让 C 头结点的 prior 指向尾部。
如果你能理解普通双向链表的合并,循环链表的合并只是多加了两行收尾操作。但如果你跳过了前面的基础,直接去写循环版,很容易把自己绕进去。这就像学数学一样,前一个知识点没弄透,后一个就是空中楼阁。
7. 总结性经验:合并操作的通用心法
7.1 指针操作的五步检查法
每次写完一个涉及指针更新的操作,我都会按固定顺序检查:
- 有没有给新节点的 prior 赋值?
- 有没有给新节点的 next 赋值?
- 有没有让前驱节点的 next 指向新节点?
- 有没有让后继节点的 prior 指向新节点?
- 有没有更新尾指针/当前位置指针?
只要这五个问题全部回答正确,链表操作基本不会出错。合并操作中,第4步最容易遗漏,第5步最容易搞反顺序。
7.2 动手前先画图的重要性
我见过太多学生直接打开编译器写链表代码,写一半发现指针乱了,然后开始一行一行地试。这其实是效率最低的方式。我个人的习惯是,拿到“合并”这个需求,先花两分钟在纸上画出合并前和合并后的状态图。哪怕你画得非常潦草,只要有 A、B、C 三个结点的方框和箭头,代码逻辑就已经在你脑海里成型了。
画图时有一个技巧:用不同颜色的笔区分“原始连接”和“新建立的连接”。这样你会发现,所谓合并,就是逐步把原链表上的灰色连接擦掉,替换成新链表上的彩色连接。这个视觉化的过程比背任何模板都有用。
7.3 我的学习路径建议
如果今天是第一次接触双向链表,我不建议直接跳到合并。你先自己实现创建、插入、删除、遍历这四个基础操作,每个都确保“正向遍历-反向遍历-释放内存”全流程没问题,再开始合并。基础操作不牢,就写合并,最后大概率会浪费三四个小时在低级 bug 上。
反过来,如果你已经在其他语言里写过双向链表,只是对 C 语言版不熟,那可以直接拿这篇文章里的代码跑一遍,然后尝试改写成循环链表版本或原地合并版本,用来验证自己是否真的理解了指针本质。
我在学习这部分内容时,反复写了很多次合并代码,每次写法都略有不同。慢慢地,“拿到需求、画出连接图、敲代码、测试边界”就变成了本能。这个习惯不仅在数据结构学习中帮了我很多,后来排查工程代码中的指针问题、内存泄漏问题时也同样奏效。希望这篇关于双向链表合并的拆解,能让你少踩几个坑,写出一次跑通的代码。
