【数据结构】双向链表的介绍和基本操作(C语言实现)【保姆级别详细教学】
先赞后看好习惯 打字不容易,这都是很用心做的,希望得到支持你 大家的点赞和支持对于我来说是一种非常重要的动力 看完之后别忘记关注我哦!️️️
干货满满~ 强烈建议本篇收藏后再食用~
看完本篇,相信你会对双向链表有一个比较深入的了解,而且会深深地体会到这一种链表相比于之前的单链表结构上的优越性、使用更为方便的特点。
文章目录
双向链表的基本介绍
一些链表的分类
在链表这一数据结构的模块里,我们可以通过它的结构,做出以下分类。
单向 | 双向 |
循环 | 非循环 |
带头 | 不带头 |
tips:带头即:带有哨兵位的头结点的链表,哨兵位头结点不存储数据。
最常用的两种链表即:
1.无头单向非循环链表
这一种单链表,若单独使用,缺陷较多,不常用
1)因此,单链表经常在OJ题里面出现。
2)另外,它是很多复杂数据结构的子结构,如图、哈希表等
2.带头双向循环链表
1)常用。
2)STL里面的list就是这种链表
因此,这两种链表都是我们必须掌握的知识点
如果对无头单向非循环链表不太了解的伙伴,可以翻看我之前的博客,先做了解,再食用本篇【数据结构】单链表的介绍和基本操作(C语言实现)【保姆级别详细教学】
带头双向循环链表的基本结构
带头双向循环链表在以下简称为双向链表,无头单向非循环链表简称单链表。
以下就是双向链表的基本结构
这种链表的结点里面,相比于单链表,多了一个prev指针,指向前一个结点。
head头结点-不存储数据,作为哨兵位使用
head头结点的prev指向链表尾
链表尾的next指向head
特殊的:链表为空的时候,并不是一个结点都没有,而是只有头结点。此时head的next和prev均指向它自己。
有了这些铺垫,我们就可以开始实现我们的双向链表了。
双向链表的实现
同样,实现这个链表需要3个源文件,这样可以使我们的程序可读性更高,更为清晰。对此不明白的伙伴可以翻看博主之前关于单链表或者扫雷游戏的作品,里面有讲解~
test.c:用于测试
List.c:用于实现接口
List.h:存放接口的声明
结点的定义、头指针的创建
学习过单链表的伙伴应该已经对这一步骤很熟悉了。
在.h
文件里创建结点
typedef int LTDataType;
typedef struct ListNode {
//指针域
struct ListNode* next;
struct ListNode* prev;
//数据域
LTDataType data;
}ListNode;
然后在.c
的main()
里创建头指针
void TestList1() {
ListNode* phead = NULL;
}
int main() {
TestList1();
return 0;
}
这个时候,我们还需要初始化一些我们的头结点,记住只有头结点链表为空。头结点初始化完之后,在后续操作中,头结点是不动的。
这里非常关键,我们知道,实现没有头的链表的时候,做头插,头删这些操作的时候,因为头结点会有可能改变,因此每次我们从
main()
里传参,都要传头指针的地址,也就是一个二级指针,但是
**带头的就不同!**头指针永不变,这表明,除了初始化头指针这个接口之外,别的接口,都不需要传二级指针!
因此,我们现在需要创建一个头指针了!
此时需要一个开辟结点的接口,我们先写这个开辟结点的接口。
开辟结点接口
ListNode* BuyListNode(LTDataType x);//.h的声明
ListNode* BuyListNode(LTDataType x) {
ListNode* node = (ListNode*)malloc(sizeof(ListNode));
node->next = NULL;//前后的指针先置空,到了操作接口里面,我们再操作这些指针
node->prev = NULL;
node->data = x;//数据置为传入的x
return node;
}
有了这个开辟结点的接口,我们可以初始化头结点了
初始化头结点接口
void ListInit(ListNode** pphead);//刚才解释过了,要传入二级指针。
void ListInit(ListNode** pphead) {//初始化的时候要定义头结点,所以要二级指针
*pphead = BuyListNode(0);
(*pphead)->next = *pphead;
(*pphead)->prev = *pphead;
//->优先级和*相同,所以括号一下
}
需要注意的点:
1.因为我们的头结点不存储数据,所以传0给开辟结点接口
2.只有头结点的时候链表为空,head的两个指针都要指向自己
接下来,我们可以先把打印接口写一写
打印接口
//打印接口
void ListPrint(ListNode* phead);
void ListPrint(ListNode* phead) {
//这里的打印和单链表就不一样了
//1.不能直接遍历
//2.哨兵位的头结点不要打印
assert(phead);//头结点肯定不能为空
ListNode* cur = phead->next;//跳过头结点
while (cur != phead) {
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
注意:
1.和单链表的遍历不一样,这里的遍历是到cur回到phead为结束标志,因为它是一个循环接口,这个很好理解
2.另外,与单链表不同,这里所有的接口都要assert(),因为头结点永远都在。
3.打印的时候要跳过头结点。
尾插接口
尾插:即在链表尾部插入一个新结点
从现在开始,我们将会深深地体会到这一种链表结构上的优越性,它们的代码实现实在是比单链表简单太多了,以致于有些地方根本不需要多解释,伙伴们都能够明白
尾插
首先,在单链表中,我们的第一步是遍历找尾,在这里我们需要这样吗?
phead的prev就是尾,根本就不用找。
因此,我们所需要做的,就是定义一个tail,让tail,phead,newnode之间的连接关系搞好,就大功告成了。
其次,在单链表中,我们重新调整结点之间连接关系的时候,常常需要临时指针储存我们的结点,为什么:怕丢,我们调整一个指针的时候,可能就会丢掉原来那个,为什么这么容易丢:因为每个结点只有一个指针指着。
而在这里,我们需要这样做吗?很明显不需要!我们每个结点都有多个指针指着,我们美美地调整连接关系就可以了。
其三:在单链表中,我们常常要在操作的时候分情况,链表为空吗,链表只有一个结点还是多个?在这里统统不需要,因为我们有带哨兵位的头结点。不明白的伙伴画个图就明白了。
我们直接上代码:
void ListPushBack(ListNode* phead, LTDataType x) {
//这种链表的尾插非常简单
//不用找尾
//头结点的prev就是尾
//而且不用判断链表是否为空,因为这是带头结点的链表
assert(phead);
ListNode* tail = phead->prev;//找到尾了
ListNode* newnode = BuyListNode(x);
//phead ...tail..newnode
//处理tail和newnode的关系
tail->next = newnode;
newnode->prev = tail;
//处理head和newnode的关系
newnode->next = phead;
phead->prev = newnode;
}
我们可以测试一下
test.c
void TestList1() {
ListNode* phead = NULL;
//初始化
ListInit(&phead);//不用二级指针,用返回的方式得到栈上开辟的新指针也是可以的
//尾插
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
}
int main() {
TestList1();
return 0;
}
其实看到这里,伙伴们应该都具有独立写出后面那些接口的能力了,可以先尝试自己写,其实真的比单链表简单很多,写完在继续食用下面的接口
尾删接口
尾删:在链表末尾删除一个结点
void ListPopBack(ListNode* phead) {
assert(phead);
//这里要稍微注意一下,链表不能为空,空了就把头结点删了,删完还要删就崩了
assert(phead->next != phead);
ListNode* tail = phead->prev;
phead->prev = tail->prev;
phead->prev->next = phead;//画个图就能明白
//这一句看不明白的可以画图,或者定义一个tailPrev也是可以的,这样更清晰
//以后尽量少写
//解决方法:定义一个tailPrev即可
free(tail);
tail = NULL;//别忘了这一句,养成好习惯
}
注意:删除结点的接口要多一个细节:判断链表是否为空,因此加多一句assert()即可。
assert(phead->next != phead);
头插接口
头插:在链表头插入一个新结点
头插其实就是在头结点和第一个结点之间插入一个新结点
很简单:定义一个first结点表示第一个结点,然后调整newnode,phead和first三者关系即可。
void ListPushFront(ListNode* phead,LTDataType x) {
//比较简单,但是要判断一下链表为空的情况
ListNode* first = phead->next;
ListNode* newnode = BuyListNode(x);
//调整关系
phead->next = newnode;
newnode->prev = phead;
newnode->next = first;
first->prev = newnode;
}
写链表要细心,对特殊情况要敏感一些,考虑链表为空的情况。
我们用同样的逻辑套一下那个空链表的特殊情况,发现上面那段代码是符合的,first就是phead自己,完全没问题
这就是双向链表的优势
头删接口
头删:删除第一个结点
定义一个first指向第一个结点,second指向第二个结点,删除first,重新调整phead和second的关系即可。
void ListPopFront(ListNode* phead) {
assert(phead);
assert(phead->next != phead);
//同样,非常简单,phead-first-second 把first free掉就可以了
ListNode* first = phead->next;
ListNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
first = NULL;
//写到这里真的是感受到了双向带头链表的优越性,毫无死角,操作非常简单
}
同样:删除的接口要有
assert(phead->next != phead);
查找接口
查找接口通常和在任意位置插入结点,在任意位置删除结点,修改结点,这些功能结合在一起,因为我们找到以后,想改可以改,想插入可以插入,想删除可以删除了。
//查找
ListNode* ListFind(ListNode* phead, LTDataType x) {
assert(phead);
ListNode* cur = phead->next;
while (cur != phead) {
if (cur->data == x) {
return cur;
}
cur = cur->next;
}
return NULL;//找了一圈都没有找到,返回空
}
插入接口
在pos位置前插入一个结点
关键还是调整结点之间的链接关系
思路非常简单,不赘述了。不明白的小伙伴可以私信留言
//插入
void ListInsert(ListNode* pos, LTDataType x) {
//在pos前面插入x
assert(pos);
ListNode* posPrev = pos->prev;
ListNode* newnode = BuyListNode(x);
//posPrev newnode pos 的链接关系
posPrev->next = newnode;
newnode->prev = posPrev;
newnode->next = pos;
pos->prev = newnode;
}
删除接口
删除pos位置的结点
//删除
void ListErase(ListNode* pos) {
assert(pos);
//注意:pos不能是phead
//assert(pos != phead);
ListNode* posPrev = pos->prev;
ListNode* posNext = pos->next;
free(pos);
pos = NULL;
posPrev->next = posNext;
posNext->prev = posPrev;
}
我们可以测试一下最后这三个接口
void TestList2() {
ListNode* phead = NULL;
ListInit(&phead);
//先尾插一些数据进去先
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
//想要在3前面插入30
ListNode* pos = ListFind(phead, 3);
ListInsert(pos, 30);
ListPrint(phead);
//删除3
pos = ListFind(phead,3);
ListErase(pos);
ListPrint(phead);
//30改300
pos = ListFind(phead, 30);
pos->data = 300;
ListPrint(phead);
}
int main() {
//TestList1();
TestList2();
return 0;
}
在本篇中博主并没有展示所有接口的测试,但是我们自己写的时候,我们每写完一个都要测试,这是一个编程的好习惯,而且测试成功也会给我们自己更多的自信。
测试代码和头文件代码的完整展示
test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include"List.h"
#if 1
void TestList1() {
ListNode* phead = NULL;
ListInit(&phead);//不用二级指针,用返回的方式得到栈上开辟的新指针也是可以的
//测试尾插
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
//测试尾删
ListPopBack(phead);
ListPopBack(phead);
ListPopBack(phead);
ListPrint(phead);
//测试头插
ListPushFront(phead, 0);
ListPushFront(phead, -1);
ListPushFront(phead, -2);
ListPushFront(phead, -3);
ListPrint(phead);
//测试头删
ListPopFront(phead);
ListPopFront(phead);
ListPopFront(phead);
ListPopFront(phead);
ListPrint(phead);
}
void TestList2() {
ListNode* phead = NULL;
ListInit(&phead);
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
//想要在3前面插入30
ListNode* pos = ListFind(phead, 3);
ListInsert(pos, 30);
ListPrint(phead);
//删除3
pos = ListFind(phead,3);
ListErase(pos);
ListPrint(phead);
//30改300
pos = ListFind(phead, 30);
pos->data = 300;
ListPrint(phead);
}
int main() {
TestList1();
TestList2();
return 0;
}
List.h
#pragma once
#include<stdio.h>
#include<assert.h>
typedef int LTDataType;
typedef struct ListNode {
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}ListNode;
//初始化
void ListInit(ListNode** pphead);
//开辟新结点接口
ListNode* BuyListNode(LTDataType x);
//打印接口
void ListPrint(ListNode* phead);
//尾插
void ListPushBack(ListNode* phead, LTDataType x);
//尾删
void ListPopBack(ListNode* phead);
//头插
void ListPushFront(ListNode* phead, LTDataType x);
//头删
void ListPopFront(ListNode* phead);
//查找(修改)
ListNode* ListFind(ListNode* phead,LTDataType x);
//插入
void ListInsert(ListNode* pos, LTDataType x);
//删除
void ListErase(ListNode* pos);
尾声
看到这里,相信伙伴们已经对带头双向循环链表已经有了比较深入的了解,掌握了基本的操作接口实现方法。相信我们已经深深感受到了这种结构的厉害之处。
如果看到这里的你感觉这篇博客对你有帮助,不要忘了收藏,点赞,转发,关注哦。