目录
一、前言
二、双向链表的基础概念
(一)双向链表的定义
(二)双向链表的优势
三、C语言实现双向链表的详细解读
(一)头文件与数据类型定义
(二)双向链表基本操作函数声明
(三)双向链表基本操作函数的具体实现
节点申请函数 BuyListNode
链表初始化函数 LTInit
链表打印函数 LTPrint
链表判空函数 LTEmpty
尾插函数 LTPushBack
尾删函数 LTPopBack
头插函数 LTPushFront
头删函数 LTPopFront
查找函数 LTFind
插入函数 LTInsert
删除函数 LTErase
销毁函数 LTDeatory
(四)双向链表的测试代码
- 替代尾插函数 LTPushBack :
- 替代尾删函数 LTPopBack :
- 替代头插函数 LTPushFront :
- 替代头删函数 LTPopFront :
四、双向链表的实际应用场景
(一)浏览器历史记录
(二)音乐播放器的歌曲列表
(三)操作系统中的进程调度
五、总结
作者主页 :共享家9527-CSDN博客
在计算机科学领域,数据结构是组织、存储和管理数据的关键方式。双向链表作为一种重要的线性数据结构,它在很多场景中都发挥着不可或缺的作用。与单向链表相比,双向链表的每个节点不仅有一个指向下一个节点的指针,还包含一个指向前一个节点的指针,这使得在链表中进行前后遍历、插入和删除操作时更加灵活高效。本文将基于提供的C语言代码,全方位、超详细地讲解双向链表的实现原理、具体代码实现以及实际应用场景。
双向链表是由一系列节点组成的数据结构。每个节点包含三个部分:数据域(用于存储实际的数据)、指向前一个节点的指针(prev)和指向后一个节点的指针(next)。通过这些指针,节点之间形成了双向的连接关系,使得我们可以从链表的任意节点出发,方便地向前或向后遍历链表。
灵活的遍历方式:单向链表只能从表头向表尾进行遍历,而双向链表可以在两个方向上进行遍历,这在某些需要频繁进行反向查找的场景中非常有用。
高效的删除操作:在单向链表中删除一个节点时,通常需要知道该节点的前一个节点才能进行操作;而在双向链表中,由于每个节点都保留了前驱节点的指针,删除操作相对更加直接和高效。
插入操作的便利性:无论是在节点之前还是之后插入新节点,双向链表都能较为轻松地通过调整指针来完成,无需像单向链表那样可能需要多次遍历查找合适的位置。
首先使用 #define _CRT_SECURE_NO_WARNINGS 来消除一些安全警告(在一些编译器环境中,使用某些函数可能会触发安全警告,这个宏定义可以抑制这些警告)。接着引入了多个必要的头文件:
c
#include
#include
#include
#include
stdio.h 用于标准输入输出操作; stdlib.h 提供了内存分配(如 malloc )和程序退出等函数; assert.h 用于断言,帮助我们在程序开发过程中检测错误; stdbool.h 则定义了 bool 类型,方便进行逻辑判断。
然后,通过 typedef 定义了数据类型 LTDataType 为 int ,后续如果需要存储其他类型的数据,只需要修改这一处定义即可,增强了代码的可扩展性。同时,定义了双向链表的节点结构体 ListNode :
c
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
struct ListNode* prev;
struct ListNode* next;
}LTNode;
每个节点包含一个数据域 data ,以及两个指针域 prev 和 next ,分别指向前驱节点和后继节点。
节点申请函数: LTNode* BuyListNode(LTDataType x); 用于创建一个新的链表节点,并将传入的数据存储在节点的数据域中。
链表初始化函数: LTNode* LTInit(); 用于创建一个初始的双向链表,通常会创建一个哨兵头节点,方便后续的操作。
链表打印函数: void LTPrint(LTNode* phead); 用于遍历并输出链表中的所有节点数据。
链表判空函数: bool LTEmpty(LTNode* phead); 用于判断链表是否为空。
尾插函数: void LTPushBack(LTNode* phead, LTDataType x); 在链表的尾部插入一个新节点。
尾删函数: void LTPopBack(LTNode* phead); 删除链表的最后一个节点。
头插函数: void LTPushFront(LTNode* phead, LTDataType x); 在链表的头部插入一个新节点。
头删函数: void LTPopFront(LTNode* phead); 删除链表的第一个有效节点(不包括哨兵头节点)。
查找函数: LTNode* LTFind(LTNode* phead, LTDataType x); 在链表中查找数据等于指定值的节点,并返回该节点的指针。
插入函数: void LTInsert(LTNode* pos, LTDataType x); 在指定节点之前插入一个新节点。
删除函数: void LTErase(LTNode* pos); 删除指定位置的节点。
销毁函数: void LTDeatory(LTNode* phead); 释放链表占用的所有内存空间。
它使用 malloc 函数从堆内存中动态分配一个 LTNode 类型的空间,如果分配失败,通过 perror 函数输出错误信息(如“malloc faile”),并返回 NULL ;如果分配成功,则将传入的数据 x 存储到节点的数据域 data 中,并将节点的前后指针 prev 和 next 都初始化为 NULL ,最后返回该节点的指针。
c
LTNode* BuyListNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc faile");
return NULL;
}
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
该函数首先调用 BuyListNode 函数创建一个哨兵头节点,并传入一个特定的值 -1 (这个值只是一个标识,具体取值不影响链表的功能)。然后将哨兵头节点的 next 和 prev 指针都指向自身,这样就创建了一个空的双向链表,并返回哨兵头节点的指针。
c
LTNode* LTInit()
{
LTNode* phead = BuyListNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
它首先使用 assert(phead) 来确保传入的头指针不为空,这是一种基本的错误检查机制。然后通过一个 while 循环,从哨兵头节点的下一个节点开始遍历链表,直到再次回到哨兵头节点。在每次循环中,输出当前节点的数据,并将指针指向下一个节点。
c
void LTPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
printf("phead=>");
while (cur != phead)
{
printf("%d=>", cur->data);
cur = cur->next;
}
printf("\n");
}
它同样先通过 assert(phead) 检查头指针的有效性,然后通过判断头节点的 next 指针是否指向自身来确定链表是否为空。如果相等,说明链表中没有有效节点,返回 true ;否则返回 false 。
c
bool LTEmpty(LTNode* phead)
{
assert(phead);
return phead->next == phead;
}
首先调用 BuyListNode 函数创建一个新节点,然后找到链表的尾部节点(即头节点的前驱节点 phead->prev )。接着调整指针关系,将新节点插入到链表尾部,使其成为新的尾部节点。
c
void LTPushBack(LTNode* phead, LTDataType x)
{
LTNode* newcode = BuyListNode(x);
LTNode* tail = phead->prev;
newcode->next = phead;
phead->prev = newcode;
tail->next = newcode;
newcode->prev = tail;
}
函数先检查头指针和链表是否为空(通过 assert 语句),然后找到链表的最后一个节点( tail )和倒数第二个节点( pretail )。通过调整倒数第二个节点的 next 指针和头节点的 prev 指针,将最后一个节点从链表中移除,最后释放该节点占用的内存,并将指针置为 NULL 。
c
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* tail = phead->prev;
LTNode* pretail = tail->prev;
pretail->next = phead;
phead->prev = pretail;
free(tail);
tail = NULL;
}
函数先确保头指针有效,然后创建一个新节点。接着获取原链表的第一个有效节点( cur ),调整指针关系,将新节点插入到表头,使其成为新的第一个有效节点。
c
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyListNode(x);
LTNode* cur = phead->next;
phead->next = newnode;
newnode->prev = phead;
newnode->next = cur;
cur->prev = newnode;
}
函数先检查头指针的有效性,然后获取原链表的第一个有效节点( cur )和第二个节点( next )。通过调整头节点的 next 指针和第二个节点的 prev 指针,将第一个有效节点从链表中移除,最后释放该节点占用的内存,并将指针置为 NULL 。
c
void LTPopFront(LTNode* phead)
{
assert(phead);
//assert(!LTEmpty(phead));
LTNode* cur = phead->next;
LTNode* next = cur->next;
phead->next = next;
next->prev = phead;
free(cur);
cur = NULL;
}
函数从链表的第一个有效节点开始遍历( cur = phead->next ),在每次循环中,检查当前节点的数据是否与目标值 x 相等。如果相等,则返回该节点的指针;如果遍历完整个链表都没有找到匹配的节点,则返回 NULL 。
c
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
函数先确保指定位置的节点指针 pos 有效,然后获取 pos 节点的前驱节点( prev ),并创建一个新节点。接着调整指针关系,将新节点插入到 pos 节点之前。
c
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;
}
函数先检查要删除的节点指针 pos 的有效性,然后获取 pos 节点的前驱节点( prev )和后继节点( next )。通过调整前驱节点的 next 指针和后继节点的 prev 指针,将 pos 节点从链表中移除,最后释放该节点占用的内存,并将指针置为 NULL 。
c
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* next = pos->next;
LTNode* prev = pos->prev;
prev->next = next;
next->prev = prev;
free(pos);
pos = NULL;
}
函数先确保头指针有效,然后从链表的第一个有效节点开始遍历( cur = phead->next ),在每次循环中,保存当前节点的后继节点指针( next ),释放当前节点占用的内存,然后将指针指向下一个节点。当遍历完所有有效节点后,释放哨兵头节点占用的内存。
c
void LTDeatory(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while(cur != phead)
{
LTNode * next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
TestList2 函数对双向链表的基本操作进行了全面的测试。首先,通过 LTInit 函数初始化一个双向链表,然后使用 LTPushFront 函数向链表头部插入多个节点,并通过 LTPrint 函数打印链表内容。接着,使用 LTFind 函数查找特定节点,获取该节点指针后,使用 LTInsert 函数在该节点之前插入一个新节点,并再次打印链表。最后,使用 LTErase 函数删除该节点,并打印删除节点后的链表。
c
void TestList2()
{
LTNode* plist = LTInit();
LTPushFront(plist, 1);
LTPushFront(plist, 2);
LTPushFront(plist, 3);
LTPushFront(plist, 4);
LTPrint(plist);
LTNode* pos = LTFind(plist, 1);
printf("%d\n", pos->data);
LTInsert(pos, 1);
LTPrint(plist);
LTErase(pos);
LTPrint(plist);
}
int main()
{
TestList2();
return 0;
}
在 main 函数中调用 TestList2 函数,启动整个测试流程。通过这些测试代码,可以直观地验证双向链表各个基本操作函数的正确性和有效性。
c
void LTPushBack(LTNode* phead,LTDataType x)
{
assert(phead);
LTInsert(phead, x);
}
这个函数通过调用 LTInsert 函数,在头节点 phead 之前插入新节点,从而实现了尾插的效果。因为头节点的前驱就是链表的尾部。
c
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTErase(phead->prev);
}
该函数先确保头节点有效且链表不为空,然后调用 LTErase 函数删除头节点的前驱节点,即链表的最后一个节点,完成尾删操作。
c
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTInsert(phead->next,x);
}
此函数调用 LTInsert 函数,在头节点的下一个节点(也就是链表的第一个有效节点)之前插入新节点,实现头插功能。
c
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTErase(phead->next);
}
函数先检查头节点有效性和链表是否为空,然后调用 LTErase 函数删除头节点的下一个节点,即链表的第一个有效节点,完成头删操作。
浏览器的历史记录功能可以使用双向链表来实现。每个节点可以存储一个网页的URL和相关信息。当用户访问新的网页时,新的记录可以通过尾插操作添加到链表中;当用户点击“后退”按钮时,可以通过向前遍历链表来访问前一个网页;点击“前进”按钮时,则可以通过向后遍历链表来访问后一个网页。删除历史记录的操作也可以方便地通过双向链表的删除函数来实现。
音乐播放器中的歌曲列表也适合用双向链表来管理。每个节点存储一首歌曲的信息,如歌曲名称、歌手等。用户可以通过双向链表的遍历操作来顺序播放或随机播放歌曲,还可以方便地实现上一曲、下一曲的切换功能。当用户添加或删除歌曲时,也可以利用双向链表的插入和删除操作来更新歌曲列表。
在操作系统中,进程调度队列可以使用双向链表来实现。每个节点代表一个进程,包含进程的相关信息,如进程ID、优先级等。调度程序可以根据不同的调度算法(如先来先服务、优先级调度等),通过对双向链表的操作来选择合适的进程运行,同时在进程状态发生变化(如进程结束、等待资源等)时,方便地调整链表中的节点顺序。
通过对以上C语言代码的详细解读,我们全面了解了双向链表的实现原理和具体实现过程。双向链表凭借其灵活的指针结构,在数据的插入、删除和遍历操作上展现出独特的优势,使其在众多实际应用场景中发挥着重要作用。掌握双向链表的实现和应用,不仅有助于我们更好地理解数据结构的本质,还能为我们在解决实际问题时提供更有效的数据组织和管理方式。在实际编程中,我们可以根据具体的需求对双向链表进行进一步的优化和扩展,以满足不同场景的要求。同时,双向链表也是学习其他复杂数据结构(如双向循环链表、双向带头循环链表等)的基础,为我们深入学习数据结构和算法奠定了坚实的基石。