在上一期我们讲述了顺序表,其在逻辑结构和物理结构上都是线性的,正是由于这种特性使得其在头部或者中间插入和删除的效率低,必须要挪动数据,其时间复杂度为O(N)。同时在顺序表空间不足时,还需要扩容,扩容有一定的消耗,且可能存在一定的空间浪费。为了提升其插入和删除的效率,我们又引入了链表的概念,其能更好的解决上述问题,所以接下来让我们深入了解一下链表吧!
2.链表
2.1链表的初步认识
链表是一种逻辑结构线性,物理存储结构非线性的一种数据结构。
其物理存储结构非连续,逻辑结构是通过链表中的指针链接实现的。
单链表是链表最基础的结构,下面是单链表的大致结构:
链表是由一个个节点构成的,所以在介绍链表结构之前,我们先了解节点的结构:
struct SListNode{
int data;//链表中存储的数据
struct SListNode*next;//下一个节点的地址
}
2.2单链表的实现
在实现这个小项目之前我们同样要创建3个文件,SList.h(包含节点的定义,接口函数的声明)SList.c(主要包含接口函数的实现),test.c(测试接口函数),这里我们实现的是无头节点(哨兵位)的单链表。
第一步我们要定义节点,节点由数据和下一个节点的地址构成,所以我们可以采用结构体来定义节点,如下所示:
typedef int SLDataType;
typedef struct SListNode {
SLDataType data;//要保存的数据
struct SListNode* next;//下一个节点的地址
}SLNode;
接下来和我们实现顺序表类似,我们要实现打印,头插头删,尾插尾删等功能。
尾插的实现:
SLNode* SLBuyNode(SLDataType x) {
SLNode* node = (SLNode*)malloc(sizeof(SLNode));
if(node==NULL)
{
perror("malloc fail");
return ;
}
node->data = x;
node->next = NULL;
return node;
}
void SLPushBack(SLNode** pphead, SLDataType x) {
//这里由于我们实现的是无头节点的单链表,为了方便我们能够打印直观的观察单链表的结构,我们在测试
//的时候会定义一个节点的指针,这个指针随后要指向单链表的第一个节点,所以我们在头插时要改变这个
//结构体类型的指针,我们这里传参要传该结构体指针的地址,所以要用二级指针来接收。
assert(pphead);//由于外面那个结构体指针的地址一定不为NULL,所以这里要判空
SLNode* node = SLBuyNode(x);//尾插要产生新的节点,所以这里用一个函数来创造新节点并返回新节
//点的地址
if (*pphead == NULL)
{
*pphead = node;
return;
}
//如果链表为空,直接让*pphead指向新节点
//说明链表不为空,找尾
SLNode* pcur = *pphead;
while (pcur->next)
{
pcur = pcur->next;
}
pcur->next = node;
}
头插的实现:
//头插
void SLPushFront(SLNode** pphead, SLDataType x) {
assert(pphead);
SLNode* node = SLBuyNode(x);
//新节点跟头结点连接起来
node->next = *pphead;//让新节点的next指向原链表的第一个节点
*pphead = node;//让新节点成为头结点
}
尾删的实现:
void SLPopBack(SLNode** pphead) {
assert(pphead);
//第一个节点不能为空
assert(*pphead);
//找到尾结点和尾结点的前一个节点
//只有一个节点的情况
if ((*pphead)->next==NULL) {
//直接把头结点删除
free(*pphead);
*pphead = NULL;
}
else {
//有多个节点的情况
//找到尾结点和尾结点前的一个节点
SLNode* prev = NULL;
SLNode* ptail = *pphead;
while (ptail->next)
{
prev = ptail;
ptail = ptail->next;
}
//prev的next指针不再指向ptail,而是指向ptail的下一个节点
prev->next = ptail->next;
free(ptail);
ptail = NULL;
}
}
头删的实现:
//1.使用临时的指针指向头结点
//2.头结点指向新的头
//3.把临时指针指向的结点释放掉
void SLPopFront(SLNode** pphead) {
assert(pphead);
assert(*pphead);
SLNode* del = *pphead;//使用临时的指针指向头结点
*pphead = (*pphead)->next; //头结点指向头结点的下一个节点
free(del);//把临时指针指向的结点释放掉,实质是释放头节点
del = NULL;//出于代码规范
}
接下来我们实际的场景当中我们可能对单链表的任意位置进行插入和删除的操作,所以我们还应该实现在任意位置的插入和删除。
在指定位置之前或指定位置之后插入数据:
//查找第一个为x的节点
SLNode* SLFind(SLNode** pphead, SLDataType x){
SLNode* pcur = *pphead;
while (pcur)
{
if (pcur->data == x) {
return pcur;//遍历单链表中的所有节点,如果节点的数据=x,则返回该节点的地址
}
pcur = pcur->next;
}
//遍历整个链表之后,如果未返回节点地址,则说明链表中不存在数据为x的节点,就返回NULL
return NULL;
}
//在指定位置之前插入数据
void SLInsert(SLNode** pphead, SLNode* pos, SLDataType x) {
//这里为了找到插入位置,我们还需要在定义一个函数,来寻找插入位置,如上所示
assert(pphead);
//约定链表不能为空,pos也不能为空
assert(pos);//pos应该为单链表中的有效位置
assert(*pphead);
SLNode* node = SLBuyNode(x);
//处理只有一个节点(pos是第一个节点)
if (pos==*pphead)
{
node->next = *pphead;
*pphead = node;
return;
}
//找到pos的前一个节点
SLNode* prev = *pphead;
while (prev->next != pos) {
prev = prev->next;
}
node->next = pos;
prev->next = node;
}
//在指定位置之后插入数据
void SLInsertAfter( SLNode* pos, SLDataType x) {
assert(pos);
SLNode* node = SLBuyNode(x);
node->next = pos->next;//让新节点的next指针指向pos位置的下一个节点
pos->next = node;/、让pos位置的结点的next指向新节点
}
删除指定节点:
//删除pos结点
void SLErase(SLNode** pphead, SLNode* pos) {
assert(pphead);
assert(*pphead);
assert(pos);
//单链表中只有一个节点
if (pos == *pphead) {
*pphead = (*pphead)->next;
free(pos);
pos=NULL;
return;
}
//找pos的前一个节点
SLNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;//让pos前一个节点的next指针指向pos的下一个节点
free(pos);//释放pos节点
pos = NULL;
}
在定义上述接口函数之后,我们要验证这些函数我们通常采用打印节点中的数据,来判断我们的操作是否有误,所以我们又创建了一个打印函数。
void SLprint(SLNode* phead) {
SLNode* pcur = phead;
while (pcur) {
printf("%d ->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
在我们用完链表之后我们通常要销毁链表以防止内存泄漏,所以最后还要创建一个销毁链表的函数。
void SLDestroy(SLNode** pphead) {
assert(pphead);
SLNode* pcur = *pphead;
//循环删除
while (pcur) {
SLNode* next = pcur->next;
free(pcur);
pcur = next;
}//遍历整个链表,依次释放删除各个节点
*pphead = NULL;//最后将链表置空
}
下面是对各个接口函数的测试
头插和尾插:
尾删:
头删:
在指定位置插入:
在指定位置删除:
2.3链表的分类:
链表分类的标准是带头或不带头,单向或双向,循环或不循环。如图所示:
根据这几种分类我们可以将它们组合起来进而构成链表,如图所示:
虽然有这么多链表的结构,但是我们实际中最常用的还是这两种结构:单链表和双向带头循环链表
如图所示:
1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际更多是作为其他数据结构的子结构,比如哈希桶,图的邻接等等。我们常常在笔试题中常见,因此我也最先把它放在最前面。
2.带头双向循环链表:结构最为复杂,一般用在单独存储数据。实际中使用的链表数据结构,基本上都是带头双向循环链表。另外这个数据结构虽然结构复杂,但是使用代码实现以后会发现这种结构会带来很多优势,随后在其讲解中我们会深入了解。
2.4双向循环带头链表的实现
双向循环链表之所以能够在实际场景中广泛应用,和它复杂而又独特的结构密切相连,其和单链表的区别在于其每个节点除了有一个后继指针之外,还有一个前驱指针,同时链表中的头结点的前驱指针指向尾结点,尾结点后继指针指向头结点,从而构成了循环结构,这样的结构同时也极大的方便我们在链表任意位置插入或者删除节点。接下来让我们实现一下吧!
同样我们还需要定义三个文件,分别是:SList.h(进行节点的定义和接口函数的声明),SList.c(接口函数的定义)test.c(测试借口函数)。
与单链表类似我们首先要定义节点的结构
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;//数据
struct ListNode* next;//后继指针
struct ListNode* prev;//前驱指针
};
与单链表不同的是双向循环链表是一种循环结构,所以在实现其接口函数之前,我们要初始化其结构。
// 双向链表的初始化
void ListInit(ListNode* head)
{
//由于这里修改的是头节点中的内容,所以我们这里传参传递的是头结点的指针
assert(head);
head->next = head;
head->prev = head;
//让其前驱指针和后继指针都指向其头结点本身
}
双向循环链表的尾插:
ListNode* ListCreate(LTDataType x)
{
ListNode* node = (ListNode*)malloc(sizeof(ListNode));
if (node == NULL)
{
perror("malloc fail");
return;
}
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
// 双向链表尾插
void ListPushBack(ListNode* head, LTDataType x)
{
assert(head);
ListNode* newnode = ListCreate(x);//尾插要用创造的新节点来尾插,所以这里要用一个函数来创造
//新节点并返回新节点的地址
///*head->prev->next = newnode;
//newnode->prev = head ->prev;
//head->prev = newnode;
//newnode->next = head;*/ //这里注意如果不记录尾指针的话,我们应该先让新节点的prev指向尾结
//点否则如果我们先让头结点的prev指向新节点话,我们就找不到尾结点了,之后的链接顺序可以随意
ListNode* tail = head->prev;//如果这里我们先记录尾结点的话,节点的链接顺序就可以是任意的
tail->next = newnode;
newnode->prev = tail;
head->prev = newnode;
newnode->next = head;
}
双向链表的尾插:
// 双向链表尾删
void ListPopBack(ListNode* head)
{
assert(head);
assert(head->prev != head);
ListNode* tail = head->prev;//记录尾结点
head->prev = tail->prev;//将头结点的prev指向尾结点的前一个节点
tail->prev->next = head;//尾结点的前一个节点的next指向头结点
free(tail);//释放头结点
tail = NULL;
}
双向链表的头插:
// 双向链表头插
void ListPushFront(ListNode* head, LTDataType x)
{
assert(head);
ListNode* newnode = ListCreate(x);//创建新节点
newnode->next = head->next;//让新节点的next指向原链表头结点的下一个节点
head->next->prev = newnode;//头节点的下一个节点的prev指向新节点
head->next = newnode;//将头节点的next指向新节点
newnode->prev = head;//将新节点prev指向头节点
}
双向链表的头删:
// 双向链表头删
void ListPopFront(ListNode* head)
{
assert(head);
assert(head->prev != head);
ListNode* cur = head->next;//将cur指向头结点的下一个节点
head->next = cur->next;//让head的next指向cur指向节点的下一个节点
cur->next->prev = head;//将cur指向节点的下一个节点的prev指向头结点
free(cur);//释放cur指向的节点
cur = NULL;
}
双向链表在指定位置之前插入数据:
// 双向链表查找
ListNode* ListFind(ListNode* head, LTDataType x)
{
assert(head);
ListNode* cur = head->next;
while (cur != head)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
// 双向链表在pos的前面进行插入
void ListInsert(ListNode* pos, LTDataType x)
{
//与单链表类似,我们需要找到插入位置的地址,即上述的ListFind函数,其返回所要找的数据所在节点的
//地址
assert(pos);
ListNode* newnode = ListCreate(x);
ListNode* posPrev = pos->prev;//记录pos位置前一个节点的地址
posPrev->next = newnode;//让pos位置的前一个节点的next指向新节点
newnode->prev=posPrev;//新节点的prev指向pos位置的前一个节点
newnode->next = pos;//让新节点的next指向pos
pos->prev = newnode;//pos的prev指向新节点
}
双向链表尾删除指定位置的节点:
// 双向链表删除pos位置的节点
void ListErase(ListNode* pos)
{
assert(pos);
pos->prev->next = pos->next;//让pos位置的前一个节点的next指向pos位置的下一个节点
pos->next->prev = pos->prev;//让pos位置的下一个节点的prev指向pos位置的前一个节点
free(pos);//释放pos
pos = NULL;
}
同时为了验证接口函数的正确性,我们还需要定义打印函数
// 双向链表打印
void ListPrint(ListNode* head)
{
assert(head);
ListNode* cur = head->next;//让cur指向头结点的下一个节点
printf("哨兵位<=>");
while (cur != head)//当cur再次指向头结点,结束循环,打印结束
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("哨兵位\n");
}
最后当我么用完双向链表之后,与单链表一样,我们要销毁它,为了防止内存泄漏。
// 双向链表销毁
void ListDestory(ListNode* head)
{
assert(head);
ListNode* cur = head->next;
while (cur != head)//遍历头结点之后的结点,依次释放
{
ListNode* next = cur->next;
free(cur);
cur = next;
}
free(head);//释放头结点,并置空
head = NULL;
}
下面则是对以上接口函数的验证:
尾插和尾删:
头插和头删:
在指定位置插入:
在指定位置删除:
今天的分享就到此结束吧,希望我的讲解能够给你带来收获,觉得有用的话,就给博主点个赞,十分感谢,你们的点赞是我前进的动力,拜拜,我们下一期再见。