实际上,学到双向链表时,对其结构的了解显得额外重要了已经:
我们要知道链表的结构一共分为以下几种:
1.单向或者双向:
2.带头或者不带头:
像这种有头节点的链表我们称之为带哨兵位的链表,哨兵位的特点是不存储有效数据。
3.循环或者非循环:
综上所述,我们发现其实链表一共有8种结构:
但是,虽然有这么多种结构,实际上我们最常用的就只是:
1.无头单向非循环链表
2.带头双向循环链表
接下来我们所介绍的就是第二种,也就是双向链表:
一.基础代码:
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}LTNode;
代码解释:
-
数据类型定义:
typedef int LTDataType;
这行代码将
int
类型重命名为LTDataType
,这样做的好处是,如果后续需要存储其他类型的数据(如double
、char*
等),只需修改这一行代码即可,而不需要改动整个链表的实现。 -
双向链表节点结构:
typedef struct ListNode { struct ListNode* next; struct ListNode* prev; LTDataType data; }LTNode;
这个结构体定义了双向链表的节点,包含三个成员:
next
:指向下一个节点的指针prev
:指向前一个节点的指针data
:节点存储的数据,类型为LTDataType
(这里是int
)
与单链表的区别就在于双向链表定义了一个前节点,用于前后链接,不难理解。
二.初始化函数:
由于具有哨兵位节点,所以我们需要初始化一下链表,创建一个哨兵位:
LTNode* LTInit()
{
LTNode* phead = BuyListNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
代码解释:
-
函数功能:
LTNode* LTInit()
该函数用于初始化一个双向循环链表,返回指向哨兵节点的指针。
-
哨兵节点创建:
LTNode* phead = BuyListNode(-1);
调用
BuyListNode
函数创建一个新节点,并初始化为哨兵节点。参数-1
通常作为哨兵节点的数据值,不代表实际数据。 -
循环链表的连接:
phead->next = phead; phead->prev = phead;
将哨兵节点的
next
和prev
指针都指向自身,形成一个空的循环链表。此时链表中只有一个哨兵节点,其前后指针构成一个环。 -
返回哨兵节点:
return phead;
返回哨兵节点的指针,作为链表的头指针。
三.创建节点函数:
同单链表一样,我们需要写一个创建节点函数:
LTNode* BuyListNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail");
return NULL;
}
node->next = NULL;
node->prev = NULL;
node->data = x;
return node;
}
代码解释:
-
内存分配:
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
使用
malloc
分配足够的内存来存储一个LTNode
结构体,并将返回的指针转换为LTNode*
类型。 -
错误处理:
if (node == NULL) { perror("malloc fail"); return NULL; }
检查内存分配是否成功。若失败(返回
NULL
),使用perror
打印错误信息并返回NULL
,调用者需检查返回值以处理内存分配失败的情况。 -
节点初始化:
node->next = NULL; node->prev = NULL; node->data = x;
将新节点的
next
和prev
指针初始化为NULL
,并将数据域设置为传入的参数x
。 -
返回节点指针:
return node;
返回指向新节点的指针,供调用者将其插入链表中。
四. 打印函数:
void LTPrint(LTNode* phead)
{
assert(phead);
printf("<=head=>");
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("\n");
}
前后的打印是为了更好的表示结构;
五.检查函数:
我们可以预想到,对于删除函数,当链表为空是不能操作的,又因为双向链表具有哨兵位,通过简单的断言无法实现链表的检查,所以我们需要写一个检查函数:
bool LTEmpty(LTNode* phead)
{
assert(phead);
return phead->next == phead;
}
代码解释:
-
断言检查:
assert(phead);
使用
assert
确保传入的头指针phead
不为NULL
。若传入NULL
,程序会触发断言失败并终止,有助于在开发阶段快速定位错误。 -
空链表判断逻辑:
return phead->next == phead;
对于带有哨兵节点的双向循环链表,当链表为空时,哨兵节点的
next
指针会指向自身(初始化时设置)。因此,通过检查phead->next == phead
即可判断链表是否为空。
函数作用
该函数用于判断双向循环链表是否为空,时间复杂度为 O (1)。结合之前的LTInit
函数,当链表初始化后且未插入任何节点时,LTEmpty
会返回true
。
注意事项
-
前置条件:
- 调用此函数前,必须确保
phead
是通过LTInit
初始化的哨兵节点,否则可能导致未定义行为。
- 调用此函数前,必须确保
六.尾插函数:
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyListNode(x);
LTNode* tail = phead->prev;
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
//LTInsert(phead,x);
//虽然是在哨兵位插入,这只是我们画图的一个逻辑,因为遍历是从phead->next开始,所以实际效果就相当于尾插;
}
代码解释:
-
断言检查:
assert(phead);
确保传入的头指针
phead
(哨兵节点)不为NULL
,防止空指针引用。 -
创建新节点:
LTNode* newnode = BuyListNode(x);
调用
BuyListNode
函数创建新节点,并初始化为存储值x
。 -
找到尾节点:
LTNode* tail = phead->prev;
在双向循环链表中,哨兵节点的
prev
指针始终指向尾节点。 -
插入新节点:
tail->next = newnode; newnode->prev = tail; newnode->next = phead; phead->prev = newnode;
- 将原尾节点的
next
指向新节点 - 新节点的
prev
指向原尾节点 - 新节点的
next
指向哨兵节点 - 哨兵节点的
prev
指向新节点
- 将原尾节点的
七.尾删函数:
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* tail = phead->prev;
LTNode* tailPrev = tail->prev;
tailPrev->next = phead;
phead->prev = tailPrev;
free(tail);
tail = NULL;
//LTEraser(phead->prev);
}
代码解释:
-
断言检查:
assert(phead); assert(!LTEmpty(phead));
- 第一个断言确保头指针(哨兵节点)不为
NULL
- 第二个断言确保链表不为空(避免删除哨兵节点)
- 第一个断言确保头指针(哨兵节点)不为
-
定位尾节点和前驱节点:
LTNode* tail = phead->prev; LTNode* tailPrev = tail->prev;
tail
:指向当前尾节点tailPrev
:指向尾节点的前一个节点
-
调整指针关系:
tailPrev->next = phead; phead->prev = tailPrev;
- 将前驱节点的
next
指向哨兵节点 - 将哨兵节点的
prev
指向前驱节点
- 将前驱节点的
-
释放内存:
free(tail); tail = NULL;
- 释放尾节点的内存
- 将局部变量
tail
置为NULL
(防止野指针,但不影响原链表结构)
八.头插函数:
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyListNode(x);
LTNode* first = phead->next;
phead->next = newnode;
newnode->prev = phead;
newnode->next = first;
first->prev = newnode;
//LTInsert(phead->next,x);
}
代码解释:
-
断言检查:
assert(phead);
确保传入的头指针(哨兵节点)不为
NULL
,防止空指针引用。 -
创建新节点:
LTNode* newnode = BuyListNode(x);
调用
BuyListNode
创建新节点,并初始化为存储值x
。 -
定位原头节点:
LTNode* first = phead->next;
first
指向当前哨兵节点的下一个节点(即原头节点)。 -
插入新节点:
phead->next = newnode; newnode->prev = phead; newnode->next = first; first->prev = newnode;
- 将哨兵节点的
next
指向新节点 - 新节点的
prev
指向哨兵节点 - 新节点的
next
指向原头节点 - 原头节点的
prev
指向新节点
- 将哨兵节点的
九.头删函数:
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* begin = phead->next;
phead->next = begin->next;
free(begin);
begin = NULL;
//LTEraser(phead->next);
}
代码解释:
-
断言检查:
assert(phead); assert(!LTEmpty(phead));
- 确保头指针(哨兵节点)不为空
- 确保链表不为空(防止删除哨兵节点)
-
定位待删除节点:
LTNode* begin = phead->next;
begin
指向当前链表的第一个数据节点
-
调整指针关系:
phead->next = begin->next;
- 将哨兵节点的
next
指针指向待删除节点的下一个节点
- 将哨兵节点的
-
释放内存:
free(begin); begin = NULL;
- 释放待删除节点的内存
- 将局部变量
begin
置为NULL
(不影响原链表结构)
十.任意位置插入函数:
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* newnode = BuyListNode(x);
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;//写完这个函数以后,头插、尾插就可以直接用;
}
代码解释:
-
断言检查:
assert(pos);
- 确保传入的位置指针
pos
不为空,防止空指针引用。
- 确保传入的位置指针
-
定位前驱节点:
LTNode* prev = pos->prev;
prev
指向pos
节点的前驱节点。
-
创建新节点:
LTNode* newnode = BuyListNode(x);
- 创建新节点并初始化为存储值
x
。
- 创建新节点并初始化为存储值
-
调整指针关系:
prev->next = newnode; newnode->prev = prev; newnode->next = pos; pos->prev = newnode;
- 将前驱节点的
next
指向新节点 - 新节点的
prev
指向前驱节点 - 新节点的
next
指向pos
节点 pos
节点的prev
指向新节点
- 将前驱节点的
十一.任意位置删除函数:
void LTEraser(LTNode* pos)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
}
不加以赘述;
当写完这两个任意位置函数以后,前面的函数就可以复用了,我在代码最后都有注释;
看到这,相信你对双向链表已经掌握不少了,是不是比单链表简单不少呢,只要你会画图!!!