一.前言
数据结构思维导图如下,灰色标记的是之前讲过的,本文将带你走近双向链表(红色标记部分),希望大家有所收获🌹🌹
二.链表的定义和概念
在讲双向链表之前,我们先学习一下链表
2.1 链表的定义
链表是一种物理存储单元上非连续非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的,即
- 逻辑结构:线性
- 物理结构:非线性
2.2 节点的构成要素
链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。
每个结点包括两个部分:一个是存储数据元素的数据域 ,另一个是存储下一个结点地址的指针域。即数据+指向下一个结点的指针,节点常用结构体实现。
c
struct ListNode
{
LTDataType data;
struct ListNode* next;
};
2.3 与数组结构的对比差异
数组 | 链表 | |
---|---|---|
存储方式 | 连续存储 需要预先分配固定大小的内存空间 | 非连续存储,通过指针连接 动态分配内存,不需要预先确定大小 |
内存使用 | 空间利用率高 (因为所有元素都紧密排列) 可能存在未使用的空间(e.g.数组大小固定但实际使用元素少) | 空间利用率相对较低 每个节点需要额外的内存来存储指针 |
插入和删除操作 | 可能需要移动大量元素以保持连续性 时间复杂度为O(n),因为可能需要移动插入点之后的所有元素 | 通常只需要改变指针,不需要移动元素 时间复杂度为O(1) (已知插入或删除位置的节点) |
随机访问 | 支持高效的随机访问,可通过索引快速访问任意元素 访问时间复杂度为O(1) | 不支持高效的随机访问,必须从头节点开始遍历链表 访问时间复杂度为O(n)。 |
空间局部性 | 有很好的空间局部性,因为元素连续存储,适合缓存优化 | 空间局部性差,因为元素分散存储,可能导致缓存未命中 |
实现复杂度 | 实现简单,大多数编程语言内置支持 | 实现相对复杂,需要手动管理节点和指针 |
适用场景 | 适合需要频繁随机访问的场景 适合元素数量已知且变化不大的场景 | 适合插入和删除操作频繁的场景 适合元素数量频繁变化的场景 |
三.链表的分类
链表的结构非常多样,以下情况组合起来就有8种(2*2*2)链表结构
链表说明:
虽然有这么多的链表结构,但是我们实际中最常用的还是两种结构:
- 单链表:不带头单向不循环链表
- 双向链表:带头双向循环链表
四.双向链表
4.1 概念
- 节点结构: 每个节点包含三个部分:指向前一个节点的指针、数据域、指向后一个节点的指针
- 一般我们都构造双向循环链表,即头结点的前驱指针指向尾结点,尾结点的后继指针指向头结点
- 插入和删除: 由于可以直接访问前一个节点和后一个节点,因此在双向链表中插入和删除节点通常比在单向链表中更高效。
- 遍历: 可以从头节点开始向前遍历,也可以从尾节点开始向后遍历。
- 复杂度: 大多数操作(如插入、删除、搜索)的时间复杂度为O(n),但因为可以双向遍历,某些情况下可以更快地定位到节点。
4.2 哨兵位
一般在设计双向链表时,我们会使用到哨兵位
(也称哨兵节点或虚拟头节点),它提供了几个好处:
- 统一接口: 哨兵位使得链表的操作接口更加统一。无论链表是否为空,插入和删除操作的代码逻辑可以保持一致,不需要特别处理空链表的情况。
- 简化操作: 哨兵位简化了插入和删除操作,特别是在链表头部或尾部的操作。因为哨兵位始终存在,所以可以直接在其前后插入或删除节点,而不需要检查链表是否为空。
- 避免空指针检查: 在没有哨兵位的情况下,每次操作前都需要检查链表是否为空,以避免空指针异常。使用哨兵位可以减少这种检查,因为可以保证总是有一个节点存在。
- 提高效率: 在某些情况下,使用哨兵位可以减少操作的复杂度。例如,在需要频繁访问链表头部和尾部的场景中,哨兵位可以提供快速访问的入口。
- 实现迭代器: 在实现迭代器或类似功能时,哨兵位可以帮助简化迭代器的状态管理,因为迭代器总是有一个起点和终点。
- 边界条件处理: 哨兵位可以帮助处理链表的边界条件,例如在遍历时,哨兵位可以作为链表结束的标志。
- 代码可读性: 使用哨兵位的代码通常更易于阅读和维护,因为所有的操作都遵循相同的模式,不需要为特殊情况编写额外的代码。
哨兵位通常是一个不存储实际数据的节点,它的 next 指针指向链表的第一个实际节点(头节点),而 prev 指针指向链表的最后一个实际节点(尾节点)。在双向链表的实现中,哨兵位通常被设置为 head 的初始值,而 tail 指向实际的尾节点。
4.3 前期准备
创建三个文件:
- List.h: 存放各种头文件和函数声明
- LIst.c : 各种函数的实现
- test.c: 测试和执行代码 最好写完一部分测试一部分 防止后期调试一堆bug
4.4 实现
首先在头文件中写上
c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>
接下来我们一步步实现双向链表各种操作
1.定义双向链表节点结构
c
typedef int LTDataType;
typedef struct ListNode
{
int data;
struct ListNode* next;
struct ListNode* prev;
}LTNode;
2.申请新节点和打印函数
因为后面会多次用到申请新节点和打印函数,所以我们把它们都各自封装成函数方便调用
c
//申请新节点
LTNode* LTBuyNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc fail!");
exit(1);
}
newnode->data = x;
//prev next 要让它自循环
newnode->next = newnode->prev = newnode;
return newnode;
}
//打印
void LTPrint(LTNode* phead)
{
LTNode* pcur = phead->next;//让pcur从第一个节点往后走
//直到走到哨兵位就跳出循环
while (pcur != phead)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
3.初始化(创建哨兵位)
c
void LTInit(LTNode** pphead)
{
//创建一个虚拟头结点(哨兵位)
*pphead = LTBuyNode(-1);
}//相当于拿着一个空瓶子让商家给我装饮料
为了保持接口的一致性,优化接口都为一级指针(在当前的学习阶段, 方法、接口、函数 是一个意思),所以我们使用下面的方法初始化
c
LTNode* LTInit2()
{
//申请新节点时已经让链表自循环了,接下来就是让哨兵位在最前面
//这里把哨兵位设置为-1(几都行,起到一个占位子的作用,实现的删除插入打印等操作都不包括哨兵位)
LTNode* phead = LTBuyNode(-1);
return phead;
}//相当于商家给我一个瓶装的饮料
4.插入
(1)尾插
c
//第一个参数传一级还是二级,要看pphead指向的节点会不会发生改变
//如果发生改变,pphead的改变要影响实参,传二级
//如果不发生改变,pphead的改变包含影响实参,传一级
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
//phead(哨兵位) phead->prev(尾节点) newnode
//先插入x,即更新newnode的prev和next
newnode->next = phead;
newnode->prev = phead->prev;//phead->prev是之前的尾节点
//再更新尾节点
phead->prev->next = newnode;//让尾节点指向新节点
phead->prev = newnode;
}
(2)头插
c
//往 第一个实际节点之前,哨兵位之后 插
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
//phead(哨兵位) newnode phead->next(第一个节点)
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
测试一下
(3)指定位置插入
因为是双向链表所以pos之前插入节点和之后插差不多
c
//pos之后插入节点
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = LTBuyNode(x);
//pos newnode pos->next
newnode->next = pos->next;
newnode->prev = pos;
pos->next->prev = newnode;
pos->next = newnode;
}
5.判断链表是否为空
下面在实现删除操作时,因为不能对链表进行删除,所以需要实现下判空操作,用到布尔类型
c
//判断链表是否为空
bool LTEmpty(LTNode* phead)
{
assert(phead);
return phead->next == phead;//如果等于的话,就自循环,链表为空,返回1。否则返回0
}
6.删除
(1)尾删
c
void LTPopBack(LTNode* phead)
{
assert(phead);//判断链表必须是有效的双向链表
assert(!LTEmpty(phead));//判断链表不能为空,为空报错
//phead Prev(del->prev) del(phead->prev)
LTNode* del = phead->prev;//要删除的尾节点
LTNode* Prev = del->prev;//尾节点的前一个节点 因为尾删需要记住尾节点的前一个节点方便之后把它设为新的尾节点
Prev->next = phead;
phead->prev = Prev;
free(del);
del = NULL;
}
(2)头删
c
void LTPopFront(LTNode* phead)
{
assert(phead);//判断链表必须是有效的双向链表
assert(!LTEmpty(phead));//判断链表不能为空,为空报错
//phead del(phead->next) del->next
LTNode* del = phead->next;//要删除的第一个实际节点
del->next->prev = phead;
phead->next = del->next;
free(del);
del = NULL;
}
(3)删除指定位置的节点
c
void LTErase(LTNode* pos)
{
assert(pos);
//pos->prev pos pos->next
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
pos = NULL;
}
7.查找
c
LTNode* LTFind(LTNode* phead, LTDataType x)
{
LTNode* pcur = phead->next;
while (pcur != phead)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
9.销毁
两种方法,一个是传二级指针,一个是传一级指针(为保持接口一致性优化过的)
法(1)
c
void LTDestory(LTNode** pphead)
{
assert(pphead && *pphead);
LTNode* pcur = (*pphead)->next;
while (pcur != *pphead)
{
LTNode* Next = pcur->next;
free(pcur);
pcur = Next;
}
pcur = NULL;//以防pcur变成野指针
//销毁哨兵位
free(*pphead);
*pphead = NULL;
}
法(2)
c
void LTDestory2(LTNode* phead)
{
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead)
{
LTNode* Next = pcur->next;
free(pcur);
pcur = Next;
}
pcur = NULL;//以防pcur变成野指针(空间被销毁但还在使用)
//销毁哨兵位
free(phead);
phead = NULL;
}
五.总结
双向链表的详细实现代码已经上传到我的资源了,大家点进主页或者在本文最上方可以下载查看,自己去写一下,边写边调试会更容易理解和掌握。
接下来会逐步介绍上述思维导图数据结构剩下的部分,创作不易,希望大家多多支持,有什么想法欢迎讨论🌹🌹