聊双链表之前,先问一个很现实的问题:2027考研408的数据结构部分,你准备在链表上拿多少分?
很多同学复习到链表时会有一种错觉——单链表会了,双链表不就是多了个 prior 指针吗,到时候现推就行。可实际上,真题里对双链表的考察从来不只停留在“知不知道多了一个前驱指针”,而是会考你“给定一段指针操作代码,结果是否还能保持逻辑正确”、“删除结点时指针该按什么顺序处理”、“为什么某些操作在双链表里能做到 O(1) 而单链表做不到”。这些不是靠背能拿下的,必须自己动手写过、踩过坑、画过图,才能稳定不丢分。
这篇东西就是专门讲双链表的实现,面向的是2027考研408的复习场景。我会把带头结点的双链表从结构体定义开始写起,逐步写完初始化、按位插入、前插、删除、遍历等核心操作,再重点分析408最常设坑的指针顺序问题,以及手写代码时的规范性习惯。无论你是刚开始过数据结构第一轮,还是已经进入强化阶段打算把代码模板固定下来,这篇内容都应该能给你一些可落地的参考。
1. 双链表在408考点中的定位:不是加分项,而是基本盘
先说结论:在408数据结构的大纲里,线性表(包括单链表、双链表、循环链表)属于“基础知识”,但每年都会以选择、综合算法题的形式出现。特别是双链表,它在选择题里出现的频率相当高,因为在指针操作的细节上,它比单链表更适合出“判断对错”和“补充缺失语句”的题目。
1.1 题型分布与分数占比
从近些年的408真题看,数据结构部分占45分,其中选择题约22道、综合题约2道。线性表的内容不一定每年都出现在大题里,但链表相关的知识点几乎每年都出现在选择题里,偶尔还会以算法设计题的方式出现。比如让考生在双链表中实现某个操作、判断某段代码的时间复杂度,或者把双链表作为某种复杂数据结构的底层存储来设计算法。
双链表本身并不是一个“很难”的知识点,但它涉及两个方向的指针维护,代码量比单链表大一圈,容易出现“写出来但边界条件不对”的情况。在考场上,这类题目属于“会的人秒选,不会的人蒙圈”的拉分题。想要稳定拿分,老老实实把实现过程吃透是最重要的。
1.2 为什么408总爱在双链表上做文章
咱们站在出题人的角度想一想:一个知识点要成为好的考点,它需要满足两个条件,一是区分度好,二是容易在大纲范围内做出“变形”。单链表的操作相对单一,你把 p->next 的顺序记熟基本就能拿分;而双链表有 next 和 prior 两个指针,两个方向的箭头会互相影响,任何一个指针先断、后断、漏接,都会导致整个链表瘫痪。
加上考研题目里特别爱考“给定一串指针操作,问执行后的效果”,或者“把一个双链表删除某个结点的代码补充完整”。这类题不考算法思想,考的就是代码层面的细致程度。这时候,如果你自己在复习阶段完整手写过一遍,眼熟,答案直接凭手感就能判断出来;如果没写过,现场去推理四个选项的指针指向,大概率会绕晕。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 存储结构设计与骨架代码:先定义对,后面才不会乱
408考题里代码部分通常用C/C++来描述,手动实现双链表的核心是结构体定义和基本的初始化。这个部分看起来简单,但它决定了后面每一个操作的写法。
2.1 结构体定义
双链表结点在单链表结点的基础上多了一个前驱指针。用C语言写出来是这样的:
c复制typedef struct DNode {
int data; // 数据域
struct DNode *prior; // 前驱指针
struct DNode *next; // 后继指针
} DNode, *DLinkList;
如果你正在复习王道或者天勤的数据结构,对这个结构应该不陌生。需要注意两点:
第一,在408的代码题里,数据域类型通常写成 ElemType 或 int,考试中用 int 不会有任何问题,关键是让阅卷人一眼看出来你知道“数据域 + 前驱 + 后继”这个结构。
第二,prior 这个指针对应前一个结点的地址,初次接触的同学容易和前驱结点的 next 搞混。记住一个原则:某个结点的 next 指向的是它“后面的兄弟”,某个结点的 prior 指向的是它“前面的兄弟”,两个指针方向完全相反。
2.2 带头结点还是不带头结点
这是一个复习408时绕不开的经典选择。从实际考试和代码统一性的角度来看,我强烈建议以“带头结点的双链表”作为练习标准。
带头结点的双链表会在真正的首元结点之前额外分配一个结构体变量,这个头结点本身不存放实际数据(数据域可以空着或者放一个特殊值),它的作用是统一空表和非空表的操作逻辑。如果不带头结点,那么“在空表中插入第一个结点”和“在非空表中插入结点”需要写两套逻辑,而且需要配合二级指针来修改头指针本身,复杂度直接翻倍。
带头结点的定义方式:
c复制// 初始化带头结点的空双链表
bool InitDLinkList(DLinkList &L) {
L = (DNode *)malloc(sizeof(DNode));
if (L == NULL) return false;
L->prior = NULL; // 头结点的前驱永远是 NULL
L->next = NULL; // 此时还没有首元结点
return true;
}
这里的“&L”是C++的引用写法,如果考场只允许C语言,就需要用二级指针 DLinkList *L。但408考试大纲并不限制语言,主流教材和辅导书默认用C++引用,考生按自己习惯写就行,关键是逻辑正确。
2.3 判空、求长度等基础操作的边界细节
带头结点的双链表判空条件是 L->next == NULL,也就是头结点后面还没有接任何结点。这个和单链表完全一致,非常简单。
求长度则需要遍历整个链表,从首元结点开始计数,这个操作在双链表里的时间复杂度和单链表一样是 O(n),没有因为多了一个指针而变快。408如果考到“求双链表长度”,代码和单链表几乎没有区别,区别只在于遍历的时候可以顺着 next 一直走,也可以用 prior 倒着走——但从头开始求长度时,只用 next 就够了。
c复制int Length(DLinkList L) {
int count = 0;
DNode *p = L->next; // 跳过头结点
while (p != NULL) {
count++;
p = p->next;
}
return count;
}
这里要提醒一个细节:408综合代码题经常要求“时间上尽可能高效”,求长度没有优化空间,但“查找倒数第k个结点”这类题就可以利用 prior 从尾结点向前走。如果题目背景是双链表,你却没用到前驱指针,那很可能是想偏了。
3. 核心操作的C语言实现:插入与删除,谁先谁后要讲道理
双链表真正让人头大的地方不是结构体,而是插入和删除时的指针顺序。很多同学看了书上的代码觉得“能看懂”,但合上书自己写就乱套,原因是没有理解指针操作的顺序逻辑,只记住了代码片段。
3.1 后插法:在p结点之后插入结点s
先讲最基础的后插,也就是在指定结点 p 的后面插入一个新结点 s。这是所有插入操作的基础,按位序插入如果默认插在“第i个位置的后面”,最终调用的也是后插。
c复制bool InsertNextDNode(DNode *p, DNode *s) {
if (p == NULL || s == NULL) return false;
s->next = p->next; // 第一步:s先指向p的后继
if (p->next != NULL) // 第二步:如果p有后继,让后继的prior指向s
p->next->prior = s;
s->prior = p; // 第三步:s的前驱指向p
p->next = s; // 第四步:p的后继指向s
return true;
}
如果你画图,会发现这四句的本质是:先把 s 和 p 的原后继“连起来”,再让 p 的 next 指向 s。这里的顺序问题是一个高频考点:如果不先让 s 指向 p 的原后继,而直接 p->next = s,那么原后继结点就找不到了,链表断裂。
实际面试或者做题常给人挖坑的写法是:
c复制p->next = s;
s->next = p->next; // 错误!此时p->next已经指向s,s->next指向自己
这种自己指向自己的环形结构,只在极特殊的场景下才有意义,在双链表里就是逻辑错误。所以写代码的原则是:改变某条“老链路”之前,先把新结点和后续需要访问的结点接入关系建立起来。
3.2 前插法:p结点之前插入结点s,为什么不用从头找
这是双链表最大的卖点之一,也是408特别喜欢对比的考点。在单链表中,想要在 p 结点之前插入一个新结点,必须从头遍历找到 p 的前驱,因为单链表无法回溯;而在双链表中,p 自己就存着前驱的地址 p->prior,所以前插操作完全不需要遍历。
最优雅的实现方法是:不把 s 真的插到 p 的前面,而是插到 p 的后面,然后交换 s 和 p 的数据域。这种“物理上后插,逻辑上前插”的技巧让代码异常简洁:
c复制bool InsertPriorDNode(DNode *p, int e) {
if (p == NULL) return false;
DNode *s = (DNode *)malloc(sizeof(DNode));
if (s == NULL) return false;
s->data = e;
s->next = p->next;
if (p->next != NULL) p->next->prior = s;
s->prior = p;
p->next = s;
// 交换数据域,让新结点在逻辑上位于p之前
int temp = p->data;
p->data = s->data;
s->data = temp;
return true;
}
这里还有一种“正道”写法,直接把 s 插在 p 和 p 的前驱之间:
c复制bool InsertPriorDNode2(DNode *p, DNode *s) {
if (p == NULL || s == NULL) return false;
DNode *pre = p->prior;
s->next = p;
p->prior = s;
s->prior = pre;
if (pre != NULL)
pre->next = s;
return true;
}
判断一下两种写法的适用场景:第一种更省代码,适合考试中要求“插入结点且数据已知”的场景;第二种是标准实现,更适合作为后续复杂数据结构的通用操作。复习时建议两种都写一遍,考场根据题目要求选择。
3.3 删除p的后继结点
删除操作是在修改前也要先把“幸存结点”保存好。最典型的场景是删除 p 后面的那个结点 q:
c复制bool DeleteNextDNode(DNode *p) {
if (p == NULL || p->next == NULL) return false; // p本身为空或p没有后继
DNode *q = p->next; // q是要被删掉的结点
p->next = q->next; // 让p直接指向q的下一个
if (q->next != NULL) // 如果q不是最后一个结点
q->next->prior = p;
free(q); // 释放结点空间
return true;
}
这个操作的逻辑看着简单,但有两处特容易被忽略。
第一,if (q->next != NULL) 这个判断不能少,如果 q 是尾结点,它的 next 本来就是 NULL,为了代码健壮性我们不允许让 NULL 的 prior 被赋值。
第二,有些同学想把“删除p自身”和“删除p的后继”合并成一个函数,绕来绕去反而容易丢掉 q 的前后连接关系。我的建议是:如果题目没特别要求,把删除自身当作“删除前驱结点的后继”来实现,因为 p 的前驱是存在的(带头结点时,头结点也可以作为前驱),这样实现最稳妥:
c复制bool DeleteDNode(DNode *p) {
if (p == NULL || p->prior == NULL) return false;
DNode *pre = p->prior;
pre->next = p->next;
if (p->next != NULL)
p->next->prior = pre;
free(p);
return true;
}
3.4 遍历与查找:正着走一遍,反着再走一遍
双链表遍历和单链表基本一致,顺着 next 一直走到 NULL 就行。但由于多了 prior,我们可以支持“从尾到头”输出,这个特性在综合题里有时能省下不少事。
c复制// 正向遍历
void PrintList(DLinkList L) {
DNode *p = L->next;
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
// 反向遍历:先走到最后一个结点,再借助prior往回走
void PrintListReverse(DLinkList L) {
DNode *p = L->next;
if (p == NULL) return;
while (p->next != NULL) p = p->next;
while (p != L) { // 头结点不需要输出
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
408真题中如果要求“判断双链表是否对称”,本质上就是同时启用一正一反两个指针,从两头向中间逐步比较。能把上面这段反向遍历写熟练,做这类题就很顺。
4. 复杂度分析与链表横向对比:双链表不可替代的理由
数据结构不是背代码,而是理解不同结构在不同场景下的取舍。408爱考的复杂度对比,就藏在双链表和单链表的差异里。
4.1 操作时间复杂度对比表
下面这张表建议自己动手整理一遍,因为选择题经常直接考“哪个操作的时间复杂度更低”。
| 操作 | 单链表 | 双链表 |
|---|---|---|
| 按值查找 | O(n) 平均 | O(n) 平均 |
| 按位查找 | O(n) 平均 | O(n) 平均 |
| 已知p,在p之后插入 | O(1) | O(1) |
| 已知p,在p之前插入 | O(1),如果采用交换数据域技巧 | O(1) 直接完成 |
| 已知p,删除p自身 | 需借助遍历找到前驱,O(n) | O(1) 借助prior |
| 从尾到头遍历 | 需要额外O(n)空间翻转 | O(n),无需额外空间 |
看到没有,双链表的优势集中体现在“已知一个结点的地址,想操作它周边”的场景。单链表在“在p之前插入”和“删除p自身”这两个操作上吃了大亏,因为找不到前驱;双链表则把“找前驱”这个动作的代价变成了 O(1)。
这就解释了一个常见疑问:既然双链表每个结点多存了一个指针,空间开销更大,为什么很多工程实现里还是用双链表?核心原因就是“删除指定结点”和“反向遍历”这两个高频需求在单链表里代价太高。比如实现一个LRU缓存淘汰策略时,哈希表能找到某个key对应的结点,随后需要在O(1)时间内把这个结点从链表中摘除并移到头部,如果底层用的是单链表,这一步的整体复杂度就会被拉高到O(n)。
4.2 408选择题里常见的“指针顺序”陷阱
真题很少直接问“双链表复杂度是多少”,而是喜欢用一个具体操作来“包装”。常见的出题方式有以下几类。
第一类,给出一段带缺省语句的双链表删除代码,让你选缺省的指针操作顺序。这种题的关键就是判断:修改 p->next 之前,是否需要先保存什么?
第二类,给出四个选项,每个选项都是一组指针操作,问“哪个能正确完成某个功能”。这种题要关注两个点:一是链表会不会断,二是会不会形成环路。比如在一个双链表中交换两个相邻结点,如果先改其中一个的前驱和后继,再改另一个,可能会出现某个结点被“孤立”,也可能出现两个结点互相指向形成死循环。
第三类,问关于带头结点双链表在尾部插入的时间复杂度。如果是带尾指针 tail 的双链表,那么尾部插入是O(1);如果只带头指针,无论单双链表,都必须遍历到尾结点再插入,复杂度是O(n)。有的同学一看到“双链表”就直接答O(1),那就掉进陷阱里了。
4.3 循环双链表:一个容易被人忽视的变种
408大纲里还涉及到循环双链表,也就是头结点的 prior 指向尾结点,尾结点的 next 指向头结点,整个链表构成环状。
循环双链表的优点是把“从任意结点出发都能访问全部结点”变为可能,空表判空条件也从 L->next == NULL 变成了 L->next == L && L->prior == L。
如果要复习到位,建议在熟练核心操作后,把所有代码都改成循环版本写一遍。你会发现大部分操作都要改边界判断:在单链表中遍历到某个结点为NULL表示结束;在循环双链表中则要判断当前结点是否回到了头结点。408统考的算法题虽然很少直接要求“请你实现一个循环双链表”,但每年不少考生都在这个变形上翻车,因为基础的非循环版本没写透,碰到略加变化的题目就慌了。
5. 手写代码避坑清单:考场上最容易丢分的三种写法
把双链表代码写对,只是及格;在考场环境下快速且零失误地写完,才是高分要求。下面这些坑,是我见过的真实案例里出现频率最高的,有些是我自己早期复习时反复踩过的。
5.1 漏判边界条件
408算法题阅卷时,一般不会运行你的代码,而是人工看关键步骤。但如果你的代码里出现 p->prior 而 p 是头结点,或者 p->next->prior 而 p 是尾结点,这类隐患如果被阅卷人看出来了,就会扣步骤分。
解决办法是形成条件反射:访问一个指针域之前,先问自己“这个结点可能为空吗?”。比如删除 p 结点的操作里,要判断 p == NULL 和 p->next == NULL;按位插入时,要判断 i 是否合法(1 <= i <= length + 1)。哪怕题目没有要求“判错返回”,把这些边界处理好也能让代码更完整。
5.2 插入/删除的顺序不严谨
再强调一遍顺序,因为这是双链表里区分度最高的一环。
插入的核心顺序是:先让新结点的指针指向“即将成为它邻居”的结点,然后再断开老结点之间的连接。打个比方,你在一个队伍中间插入一个人,正确做法是让这个人先抓住前面人的衣服、再拉住后面人的手,然后才让前面和后面的人松开彼此。如果先让前面的松开手,后面的人就没人拉着了,队伍就断了。
删除的核心顺序是:先把孤儿结点挂接到前一个结点上,再把后一个结点的前驱指回来。你可以理解为:把中间的人带走前,先让前面的人和后面的人互相牵手。
这两个顺序不是死板的,但建议每位考生在纸上画5遍以上,形成肌肉记忆。
5.3 操作后忘记处理释放的结点
408大题的C语言代码里,删除结点不 free 严格来说不算“错”,因为有些题目根本没让你写完整程序。但如果题目用了“删除”这个动词,并且明确要求“释放被删除结点空间”,那就必须写 free(q)。
值得注意的是,free 之后,如果有人仍然持有指向被释放空间的指针,会产生悬垂指针。在考卷上,阅卷人不会追查这一点,但在实际工程项目中这就是内存安全问题的源头。不要觉得408只是应试,很多复试环节会问项目、问工程实践,把“删除后free”这个习惯内化成肌肉记忆,对你只有好处。
6. 完整可运行的参考代码与校验方式
光看片段不如直接拷一段完整代码跑一遍,跑通了再改出各种错误版本去验证自己对原理的理解。这个“故意写错再排查”的过程,比单纯抄代码有用得多。
6.1 带头结点双链表的完整实现
c复制#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef struct DNode {
int data;
struct DNode *prior;
struct DNode *next;
} DNode, *DLinkList;
// 初始化
bool InitList(DLinkList &L) {
L = (DNode *)malloc(sizeof(DNode));
if (L == NULL) return false;
L->prior = NULL;
L->next = NULL;
return true;
}
// 求长度
int Length(DLinkList L) {
int count = 0;
DNode *p = L->next;
while (p != NULL) {
count++;
p = p->next;
}
return count;
}
// 按位查找,返回第i个结点,i从1开始
DNode *GetElem(DLinkList L, int i) {
if (i < 1) return NULL;
DNode *p = L->next;
int j = 1;
while (p != NULL && j < i) {
p = p->next;
j++;
}
return p;
}
// 后插:在p结点之后插入新结点s
bool InsertNextNode(DNode *p, DNode *s) {
if (p == NULL || s == NULL) return false;
s->next = p->next;
if (p->next != NULL)
p->next->prior = s;
s->prior = p;
p->next = s;
return true;
}
// 按位插入:在第i个位置插入元素e(带头结点)
bool ListInsert(DLinkList &L, int i, int e) {
if (i < 1) return false;
DNode *p = L;
int j = 0;
while (p != NULL && j < i - 1) {
p = p->next;
j++;
}
if (p == NULL) return false;
DNode *s = (DNode *)malloc(sizeof(DNode));
if (s == NULL) return false;
s->data = e;
return InsertNextNode(p, s);
}
// 删除p的后继结点
bool DeleteNextNode(DNode *p) {
if (p == NULL || p->next == NULL) return false;
DNode *q = p->next;
p->next = q->next;
if (q->next != NULL)
q->next->prior = p;
free(q);
return true;
}
// 删除第i个结点,并用e返回被删元素的值
bool ListDelete(DLinkList &L, int i, int &e) {
if (i < 1) return false;
DNode *p = L;
int j = 0;
while (p != NULL && j < i - 1) {
p = p->next;
j++;
}
if (p == NULL || p->next == NULL) return false;
DNode *q = p->next;
e = q->data;
p->next = q->next;
if (q->next != NULL)
q->next->prior = p;
free(q);
return true;
}
// 正向输出
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->next;
if (p == NULL) return;
while (p->next != NULL)
p = p->next;
while (p != L) {
printf("%d ", p->data);
p = p->prior;
}
printf("\n");
}
// 销毁整个链表
void DestroyList(DLinkList &L) {
while (L->next != NULL)
DeleteNextNode(L);
free(L);
L = NULL;
}
int main() {
DLinkList L;
InitList(L);
// 插入5个元素
ListInsert(L, 1, 10);
ListInsert(L, 2, 20);
ListInsert(L, 3, 30);
ListInsert(L, 4, 40);
ListInsert(L, 5, 50);
printf("正向: ");
PrintList(L); // 10 20 30 40 50
printf("反向: ");
PrintListReverse(L); // 50 40 30 20 10
int e;
ListDelete(L, 3, e); // 删除第3个元素
printf("删除元素: %d\n", e); // 30
printf("删除后: ");
PrintList(L); // 10 20 40 50
printf("长度: %d\n", Length(L)); // 4
DestroyList(L);
return 0;
}
6.2 怎么检验自己真的会了
检验层面我推荐“三步验证法”。
第一步,把代码敲进编译器里跑通,观察输出。第二步,用纸笔画出每一步操作后链表的结构,重点跟踪被插入/删除结点的 next 和 prior 变化,和程序输出做对照。第三步,故意制造几个常见错误版本,比如不判断 p->next != NULL 就访问 p->next->prior,或者把后插的四句顺序打乱,运行看会发生什么,再尝试解释现象。
这个过程大概需要两小时,但它带来的收益是长期性的。408考试里遇到指针操作题,你就不需要靠“猜”了,而是像看老朋友一样一眼识别出代码意图。
7. 常见问题与该记到纸上的“最后提醒”
把复习过程中大家最常问的几个问题整理一下,里面有不少是408群里每年都会出现的讨论焦点。
7.1 考试时用C还是C++的引用?
408官方并不指定语言,实际上阅卷时不看具体语法,只看你的逻辑关键点。C语言的二级指针写法更通用,C++的引用写法更简洁。如果目标只是应对笔试,选自己最熟练的即可。但如果你要参加机试或复试上机,强烈建议习惯用C语言风格,因为很多学校的机试环境对C++标准支持不统一,写太花哨的特性反而容易出问题。
7.2 双链表需要掌握到什么程度算够?
至少要做到:不看书能写出带完整边界判断的初始化、按位插入、按位删除、按值删除、正向和反向遍历、销毁这六类操作。在这个基础上,要把“在指定结点前插入”和“删除指定结点”的O(1)实现练到条件反射。
408的复习时间有限,如果还处于第一轮,不建议把大量时间花在循环双链表的复杂变形上,先把基础版本吃透。第二轮强化时再扩展到循环双链表,并尝试分析它和普通双链表在边界上的差异。
7.3 会不会在算法大题里单独考双链表?
从近几年的真题风格看,直接让你“编写一个双链表算法”的题目非常少见,但双链表经常作为底层的存储结构出现在综合题里,例如利用双向链表实现某种缓存策略、或者实现要求 O(1) 删除指定元素的容器。这样一来,如果你没掌握双链表的删除和插入,大题的第一小步就会卡住,后面的小问基本全废。
所以建议复习算法题时,把“双链表 + O(1) 删除指定结点”这个组合场景当作核心考点来对待,自己设计一个类似“哈希表 + 双链表”的LRU缓存结构,一边复习考研内容,一边也给复试项目积累素材。
7.4 写算法题时要不要写退化情况和节点释放的处理
要写,但不用过度。阅卷评分一般是按点给分,主要的算法逻辑对了,退出循环条件对了,一般就能拿大部分分。边界条件的处理属于“锦上添花”,写到哪一步取决于你的时间。
我个人建议的顺序是:先保证主体逻辑正确,再补边界判断,最后再加释放和异常返回。千万不要为了把边界写漂亮而让主流程出现漏洞,那样就舍本逐末了。
最后再分享一个实在的经验:复习双链表最忌讳只看不写,也最忌讳只抄不画。每写一个函数,就在草稿纸上画出操作前后的链接变化,把每个箭头的指向标清楚。408选择题里的指针题目通常不需要长篇大论的推导,大多数时候你只是在“看图说话”,而这个图,务必在平时就刻进脑子里。
