目录
[1.1 什么是 "哨兵位"?](#1.1 什么是 “哨兵位”?)
[1.2 带头双向循环链表的完整结构](#1.2 带头双向循环链表的完整结构)
[二、双向链表的完整实现(C 语言)](#二、双向链表的完整实现(C 语言))
[2.1 辅助函数:创建新节点(LTBuyNode)](#2.1 辅助函数:创建新节点(LTBuyNode))
[2.2 初始化链表(LTInit)](#2.2 初始化链表(LTInit))
[2.3 销毁链表(LTDestroy)](#2.3 销毁链表(LTDestroy))
[2.4 打印链表(LTPrint)](#2.4 打印链表(LTPrint))
[2.5 判断链表是否为空(LTEmpty)](#2.5 判断链表是否为空(LTEmpty))
[2.6 尾部插入(LTPushBack)](#2.6 尾部插入(LTPushBack))
[2.7 头部插入(LTPushFront)](#2.7 头部插入(LTPushFront))
[2.8 头部删除(LTPopFront)](#2.8 头部删除(LTPopFront))
[2.9 尾部删除(LTPopBack)](#2.9 尾部删除(LTPopBack))
[2.10 查找节点(LTFind)](#2.10 查找节点(LTFind))
[2.11 指定位置插入(LTInsert)](#2.11 指定位置插入(LTInsert))
[2.12 指定位置删除(LTErase)](#2.12 指定位置删除(LTErase))
[4.1 核心差异对比表](#4.1 核心差异对比表)
[4.2 应用场景选择建议](#4.2 应用场景选择建议)
[五、扩展:链表与数组的 "移除元素" 实现对比](#五、扩展:链表与数组的 “移除元素” 实现对比)
[5.1 数组移除元素(removeElement)](#5.1 数组移除元素(removeElement))
[5.2 双向链表移除元素](#5.2 双向链表移除元素)
[5.3 两种结构移除元素的核心差异](#5.3 两种结构移除元素的核心差异)
在数据结构的学习中,链表是与顺序表并列的重要线性表结构,而双向链表作为链表的进阶形式,凭借其前后双向访问的特性,在诸多场景中展现出比单链表更灵活的优势。本文将从双向链表的核心结构入手,完整讲解其实现细节,对比顺序表与双向链表的差异,并通过实例代码演示双向链表的实际应用,帮助读者全面掌握这一数据结构。
一、双向链表的核心结构:带头双向循环链表
双向链表的结构有多种形式,其中带头双向循环链表是实现最便捷、实用性最高的版本。在深入结构细节前,需先明确一个关键概念 ------"哨兵位"。
1.1 什么是 "哨兵位"?
在单链表中,我们常将第一个存储有效数据的节点称为 "头节点",但这种称呼并不严谨。而在带头双向循环链表中,"头节点" 的真正身份是哨兵位节点:
- 哨兵位节点不存储任何有效数据,仅作为链表的 "标志位" 存在。
- 其核心作用是避免遍历循环链表时出现死循环,同时统一链表的插入、删除操作逻辑(无需单独处理表头、表尾的特殊情况)。
1.2 带头双向循环链表的完整结构
该结构的核心特征可概括为 "双向" 与 "循环":
- 双向 :每个节点包含两个指针,
prev
指向前驱节点,next
指向后继节点,实现前后双向访问。 - 循环 :链表的尾节点的
next
指针指向哨兵位,哨兵位的prev
指针指向尾节点,形成闭合循环。
结构示意图如下(head
为哨兵位节点):
cpp
head(哨兵位)
↓ ↑
prev next
↓ ↑
节点1 ←→ 节点2 ←→ 节点3 ←→ ... ←→ 尾节点
节点的结构体定义(C 语言):
cpp
// 定义双向链表节点的数据类型
typedef int LTDataType;
// 双向链表节点结构体
typedef struct ListNode
{
struct ListNode* prev; // 指向前驱节点的指针
struct ListNode* next; // 指向后继节点的指针
LTDataType data; // 存储节点的有效数据
} LTNode;
二、双向链表的完整实现(C 语言)
双向链表的实现围绕 "初始化、销毁、增删查改、判空、打印" 等核心操作展开。以下基于带头双向循环链表结构,逐一讲解实现细节,并附上完整代码。
2.1 辅助函数:创建新节点(LTBuyNode)
所有插入操作都需要先创建新节点,因此封装一个通用的 "创建节点" 函数,避免代码冗余:
cpp
// 创建一个新的双向链表节点,数据为x,初始时prev和next均指向自身
LTNode* LTBuyNode(LTDataType x)
{
// 为新节点分配内存
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
// 检查内存分配是否成功
if (node == NULL)
{
perror("malloc failed"); // 打印错误信息
exit(-1); // 终止程序(内存分配失败属于严重错误)
}
// 初始化节点数据和指针
node->data = x;
node->prev = node; // 初始时自环(便于后续插入逻辑统一)
node->next = node;
return node;
}
2.2 初始化链表(LTInit)
初始化的核心是创建哨兵位节点,此时链表仅包含哨兵位,且哨兵位的prev
和next
均指向自身:
cpp
// 初始化双向链表,返回哨兵位节点的地址
LTNode* LTInit()
{
// 创建哨兵位节点,数据设为-1(无实际意义,仅占位)
LTNode* phead = LTBuyNode(-1);
return phead;
}
2.3 销毁链表(LTDestroy)
链表的销毁需要释放所有节点(包括哨兵位),避免内存泄漏。由于是循环结构,需从哨兵位的下一个节点开始遍历,逐个释放:
cpp
// 销毁双向链表,释放所有节点内存
void LTDestroy(LTNode* phead)
{
assert(phead); // 断言:确保phead不为空(避免传入无效指针)
LTNode* pcur = phead->next; // 从哨兵位的下一个节点开始遍历
while (pcur != phead) // 循环终止条件:回到哨兵位(所有节点已遍历)
{
LTNode* next = pcur->next; // 先保存下一个节点的地址(避免释放后找不到)
free(pcur); // 释放当前节点
pcur = next; // 移动到下一个节点
}
free(phead); // 最后释放哨兵位节点
phead = NULL; // 将指针置空(避免野指针)
}
2.4 打印链表(LTPrint)
打印链表时,从哨兵位的下一个节点开始,遍历到哨兵位结束(避免打印哨兵位的无效数据):
cpp
// 打印双向链表的所有有效数据
void LTPrint(LTNode* phead)
{
assert(phead); // 确保phead不为空
LTNode* pcur = phead->next;
printf("哨兵位 -> ");
while (pcur != phead)
{
printf("%d -> ", pcur->data); // 打印当前节点数据
pcur = pcur->next; // 移动到下一个节点
}
printf("哨兵位\n"); // 打印结尾,体现循环结构
}
2.5 判断链表是否为空(LTEmpty)
链表为空的标志是:哨兵位的next
指针指向自身(无任何有效节点):
cpp
// 判断双向链表是否为空(无有效节点),空则返回true,否则返回false
bool LTEmpty(LTNode* phead)
{
assert(phead);
// 哨兵位的next指向自身 → 无有效节点
return phead->next == phead;
}
2.6 尾部插入(LTPushBack)
尾部插入的核心是找到尾节点(哨兵位的prev
指向尾节点),然后将新节点插入到尾节点与哨兵位之间:
cpp
// 在双向链表尾部插入数据x
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead); // 确保phead不为空(哨兵位存在)
LTNode* newnode = LTBuyNode(x); // 创建新节点
LTNode* tail = phead->prev; // 找到尾节点(哨兵位的前驱)
// 调整指针,插入新节点
tail->next = newnode; // 尾节点的next指向新节点
newnode->prev = tail; // 新节点的prev指向尾节点
newnode->next = phead; // 新节点的next指向哨兵位
phead->prev = newnode; // 哨兵位的prev指向新节点(新节点成为新尾)
}
2.7 头部插入(LTPushFront)
头部插入是在哨兵位与第一个有效节点之间插入新节点,直接利用哨兵位的next
指针即可定位插入位置:
cpp
// 在双向链表头部(哨兵位之后)插入数据x
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
LTNode* first = phead->next; // 找到第一个有效节点(哨兵位的后继)
// 调整指针,插入新节点
phead->next = newnode; // 哨兵位的next指向新节点
newnode->prev = phead; // 新节点的prev指向哨兵位
newnode->next = first; // 新节点的next指向原第一个节点
first->prev = newnode; // 原第一个节点的prev指向新节点
}
2.8 头部删除(LTPopFront)
头部删除需先判断链表是否为空,然后删除哨兵位后的第一个有效节点:
cpp
// 删除双向链表头部(哨兵位之后)的第一个有效节点
void LTPopFront(LTNode* phead)
{
// 断言:链表不为空(哨兵位存在且有有效节点)
assert(phead && !LTEmpty(phead));
LTNode* del = phead->next; // 找到要删除的节点(第一个有效节点)
LTNode* second = del->next; // 保存删除节点的下一个节点
// 调整指针,跳过删除节点
phead->next = second; // 哨兵位的next指向原第二个节点
second->prev = phead; // 原第二个节点的prev指向哨兵位
free(del); // 释放删除节点的内存
del = NULL; // 避免野指针
}
2.9 尾部删除(LTPopBack)
尾部删除与头部删除逻辑对称,删除哨兵位的前驱节点(尾节点):
cpp
// 删除双向链表尾部的节点
void LTPopBack(LTNode* phead)
{
assert(phead && !LTEmpty(phead));
LTNode* del = phead->prev; // 找到要删除的尾节点
LTNode* prevTail = del->prev; // 保存尾节点的前驱节点
// 调整指针,跳过删除节点
prevTail->next = phead; // 原尾节点的前驱指向哨兵位
phead->prev = prevTail; // 哨兵位的prev指向原尾节点的前驱
free(del);
del = NULL;
}
2.10 查找节点(LTFind)
遍历链表,查找数据等于x
的节点,找到则返回节点地址,未找到则返回NULL
:
cpp
// 在双向链表中查找数据x,找到返回节点地址,未找到返回NULL
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead)
{
if (pcur->data == x)
{
return pcur; // 找到目标节点,返回地址
}
pcur = pcur->next;
}
return NULL; // 遍历结束未找到
}
2.11 指定位置插入(LTInsert)
在指定节点pos
的后面插入新节点,利用双向链表的前后指针,无需遍历即可完成插入:
cpp
// 在指定节点pos的后面插入数据x
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos); // 确保pos是有效的节点地址
LTNode* newnode = LTBuyNode(x);
LTNode* posNext = pos->next; // 保存pos的下一个节点
// 调整指针,插入新节点
pos->next = newnode; // pos的next指向新节点
newnode->prev = pos; // 新节点的prev指向pos
newnode->next = posNext; // 新节点的next指向posNext
posNext->prev = newnode; // posNext的prev指向新节点
}
2.12 指定位置删除(LTErase)
删除指定节点pos
,同样利用双向指针直接调整,无需遍历:
cpp
// 删除指定节点pos
void LTErase(LTNode* pos)
{
assert(pos); // 确保pos有效
LTNode* posPrev = pos->prev; // 保存pos的前驱节点
LTNode* posNext = pos->next; // 保存pos的后继节点
// 调整指针,跳过pos节点
posPrev->next = posNext; // 前驱节点的next指向后继节点
posNext->prev = posPrev; // 后继节点的prev指向前驱节点
free(pos); // 释放pos节点内存
pos = NULL; // 避免野指针
}
三、双向链表的测试实例(test.c)
为验证双向链表的所有操作是否正确,编写测试代码,涵盖初始化、插入、查找、删除、销毁等流程:
cpp
#include "List.h" // 包含双向链表的头文件
int main()
{
// 1. 初始化链表
LTNode* plist = LTInit();
printf("初始化后链表(空):");
LTPrint(plist);
// 2. 尾部插入4个元素:1、2、3、4
LTPushBack(plist, 1);
LTPushBack(plist, 2);
LTPushBack(plist, 3);
LTPushBack(plist, 4);
printf("尾部插入1、2、3、4后:");
LTPrint(plist); // 预期输出:哨兵位 -> 1 -> 2 -> 3 -> 4 -> 哨兵位
// 3. 头部插入元素0
LTPushFront(plist, 0);
printf("头部插入0后:");
LTPrint(plist); // 预期输出:哨兵位 -> 0 -> 1 -> 2 -> 3 -> 4 -> 哨兵位
// 4. 查找元素3,并在其后面插入30
LTNode* find = LTFind(plist, 3);
if (find != NULL)
{
LTInsert(find, 30);
printf("在3后面插入30后:");
LTPrint(plist); // 预期输出:... -> 3 -> 30 -> 4 -> ...
}
// 5. 删除元素30
if (find != NULL)
{
LTErase(find->next); // find->next是30的节点
printf("删除30后:");
LTPrint(plist); // 预期输出:... -> 3 -> 4 -> ...
}
// 6. 头部删除和尾部删除
LTPopFront(plist);
LTPopBack(plist);
printf("头部删除和尾部删除后:");
LTPrint(plist); // 预期输出:哨兵位 -> 1 -> 2 -> 3 -> 哨兵位
// 7. 销毁链表
LTDestroy(plist);
plist = NULL; // 避免野指针
return 0;
}
四、顺序表与双向链表的深度对比
顺序表(如动态数组)和双向链表是两种常用的线性表结构,二者在存储空间、访问效率、插入删除效率等方面差异显著,选择需结合具体应用场景。
4.1 核心差异对比表
对比维度 | 顺序表(动态数组) | 双向链表(带头循环) |
---|---|---|
存储空间 | 物理上连续(依赖数组) | 逻辑上连续,物理上不连续(节点分散) |
随机访问 | 支持 O (1)(通过下标直接访问) | 不支持,需遍历 O (N) |
插入 / 删除效率 | 任意位置插入 / 删除需搬移元素,O (N) | 已知位置时仅需调整指针,O (1) |
容量管理 | 动态扩容(可能存在内存浪费) | 无容量概念,按需分配节点 |
内存利用率 | 扩容后可能有空闲内存(如 2 倍扩容) | 无空闲内存,每个节点仅存储必要数据 |
缓存友好性 | 好(连续存储,命中缓存概率高) | 差(节点分散,缓存命中率低) |
4.2 应用场景选择建议
- 优先选顺序表:当需求以 "频繁访问"(如随机查询、遍历)为主,插入 / 删除仅在尾部进行时(如日志存储、数组排序),顺序表的 O (1) 访问和缓存优势更明显。
- 优先选双向链表:当需求以 "频繁插入 / 删除" 为主(如链表中部插入、删除),且无需随机访问时(如链表式队列、栈、LRU 缓存),双向链表的 O (1) 插入 / 删除效率更优。
五、扩展:链表与数组的 "移除元素" 实现对比
为进一步理解链表与数组的差异,我们对比 "移除元素" 功能的实现(分别基于单链表和数组)。
5.1 数组移除元素(removeElement)
数组移除元素需通过 "双指针" 实现,本质是 "覆盖无效元素",时间复杂度 O (N):
cpp
// 移除数组nums中值为val的元素,返回剩余元素个数
int removeElement(int* nums, int numsSize, int val)
{
int src = 0; // 遍历指针:寻找非val的元素
int dst = 0; // 目标指针:指向待覆盖的位置
while (src < numsSize)
{
if (nums[src] != val)
{
nums[dst] = nums[src]; // 用非val元素覆盖dst位置
dst++; // 目标指针后移,准备下一次覆盖
}
src++; // 遍历指针后移,继续检查下一个元素
}
return dst; // dst的值即为剩余有效元素的个数
}
数组移除元素的核心局限在于物理存储连续:即使找到需移除的元素,也需通过后续元素 "向前覆盖" 来实现 "移除" 效果,若数组长度较大,大量元素的搬移会导致效率降低,这与顺序表 "任意位置插入删除效率低" 的特性一致。
5.2 双向链表移除元素
基于本文讲解的带头双向循环链表,移除指定值元素的操作可结合LTFind
与LTErase
函数实现,时间复杂度取决于查找过程(遍历链表为 O (N)),移除操作本身仅需调整指针(O (1)):
cpp
// 移除双向链表中所有值为val的元素
void LTRemoveVal(LTNode* phead, LTDataType val)
{
assert(phead); // 确保哨兵位节点有效
LTNode* pcur = phead->next; // 从第一个有效节点开始遍历
while (pcur != phead) // 遍历终止条件:回到哨兵位
{
LTNode* nextNode = pcur->next; // 提前保存下一个节点地址(避免删除后丢失)
if (pcur->data == val)
{
LTErase(pcur); // 调用已有删除函数,调整指针并释放节点
}
pcur = nextNode; // 遍历到下一个节点
}
}
双向链表移除元素的优势在于无需搬移元素 :找到目标节点后,仅需通过prev
和next
指针连接其前驱与后继节点,再释放目标节点内存即可,完全规避了数组 "元素覆盖" 的效率问题,这也体现了链表 "任意位置插入删除灵活" 的核心特性。
5.3 两种结构移除元素的核心差异
对比维度 | 数组(顺序表) | 双向链表 |
---|---|---|
实现核心 | 双指针覆盖无效元素 | 调整节点指针 + 释放目标节点内存 |
时间复杂度 | O (N)(遍历 + 元素覆盖) | O (N)(遍历查找)+ O (1)(移除操作) |
空间复杂度 | O (1)(原地操作) | O (1)(仅需指针临时存储) |
关键局限 | 元素搬移导致效率损耗 | 需提前遍历查找目标节点(无随机访问) |
六、总结
本文系统梳理了双向链表的核心知识:首先明确了带头双向循环链表的结构,重点区分了 "哨兵位" 与单链表 "头节点" 的差异,阐述了哨兵位避免死循环、统一操作逻辑的关键作用;其次围绕专题给出的函数声明,讲解了双向链表初始化、增删查改、销毁等操作的实现逻辑,突出其 "双向指针调整" 的核心优势;最后通过与顺序表的对比,明确了二者在存储空间、效率、应用场景上的差异。
双向链表的核心价值在于解决了单链表 "只能单向访问" 和顺序表 "插入删除效率低" 的问题,尤其适合频繁在任意位置进行插入删除的场景。掌握其结构设计与实现逻辑,不仅能提升数据结构的应用能力,也能为后续更复杂的链表或数据结构学习奠定基础。
