目录
1.前言
在之前的博客中谈论顺序表的缺点讲到:
顺序表一般需要预先分配一定大小的存储空间,如果存储空间不够用,就需要重新分配更大的空间。如果内存中空间足够,realloc函数会在原内存空间的基础上向后开辟新的内存空间。但是如果后面的内存空间不足,realloc函数就需要先拷贝原顺序表的数据到新开辟的内存空间,再释放原顺序表的内存空间,当数据很多时,这种操作的牺牲是十分巨大的。
那有没有一种数据结构能够优化这个缺点呢?这种数据结构就是链表。
本篇博客将会涉及单链表、带头单链表等链表类型,以及这些链表在C语言中的实现。
2.链表的概念
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
简单来说,链表是用一个结构体,在这个结构体中分别存储数据和指向下一个结构体的指针,以此来形成链状结构(逻辑结构)。像链表这样的数据结构就没有顺序表考虑容量的问题了。
3.链表的类型
3.1.单链表
3.1.1单链表的实现
头文件
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
// slist.h
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SListNode;
// 动态申请一个节点
SListNode* BuySListNode(SLTDataType x);
// 单链表打印
void SListPrint(SListNode* plist);
// 单链表尾插
void SListPushBack(SListNode** pplist, SLTDataType x);
// 单链表的头插
void SListPushFront(SListNode** pplist, SLTDataType x);
// 单链表的尾删
void SListPopBack(SListNode** pplist);
// 单链表头删
void SListPopFront(SListNode** pplist);
// 单链表查找
SListNode* SListFind(SListNode* plist, SLTDataType x);
// 单链表在pos位置之后插入x
void SListInsertAfter( SListNode* pos, SLTDataType x);
// 单链表删除pos位置之后的值
void SListEraseAfter(SListNode** pplist, SListNode* pos);
// 在pos的前面插入
void SLTInsert(SListNode** pphead, SListNode* pos, SLTDataType x);
// 删除pos位置
void SLTErase(SListNode** pphead, SListNode* pos);
//销毁顺序表
void SLTDestroy(SListNode** pplist);
这里我们创建一个叫做SListNode的结构体,并typedef它,这样在后续使用这个结构体时就不用打struct了。同样,在上面我们把int类型typedef成SLDataType,这样如果我们要存储其他类型的数据时,只需要把这里的int的改成需要存储的类型,就可以一键更改存储的数据类型了。
在这个结构体中,data用来存储数据,next是指向下一个结构体变量的指针。
无头单链表的结构
由于用于演示的单链表没有不存储有效数据的头节点,所以在传参时,必须传一个二级指针,才能在外部更改链表里的数据和指针。在上面的代码中。pplist是指向指向链表首元素的指针的指针(pointer to pionter to list),有点绕,看下面的图方便理解一些。plist是指向链表首元素的指针(pionter to list),plist的next指向链表的第二个元素,链表中的元素按顺序相连,最后一个元素的next指向空指针(NULL)。这就是一个简单的无头单链表的基本结构了。
图示:
3.1.2增删查改功能
这里的顺序按上面给出的头文件函数顺序
3.1.2.1.创建节点
SListNode* BuySListNode(SLTDataType x)
{
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
这里我们通过malloc函数向内存申请一个空间,并把这个空间给到newnode变量。注意下方判断newnode是否创建成功的语句是必要的,它能在malloc申请空间失败后(即newnode指向空),向屏幕输出精确的错误原因呢并终止程序。这个函数一般是在嵌套在添加数据的函数中的,所以要把newnode作为返回值赋予增添数据创建的变量(具体看下面添加数据的函数)。
3.1.2.2.打印链表
void SListPrint(SListNode* plist)
{
SListNode* cur = plist;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
这里我们创建一个cur变量指向plist的空间,这样cur就能访问plist内的data和next,打印完当前数据之后再让cur等于cur指向的下一个结构体(cur=cur->next),直到cur指向空指针。
图示:
3.1.2.3.尾插数据
void SListPushBack(SListNode** pplist, SLTDataType x)
{
SListNode* newnode = BuySListNode(x);
//如果是空链表
if (*pplist == NULL)
{
*pplist = newnode;
}
else
{
//找尾
SListNode* tail = *pplist;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
这里我们先创建一个newnode变量向内存申请空间(详细看上面的创建节点),接着先判断单链表有没有存储数据,是不是空链表(即pplist指向空),如果是空链表,就让newnode作为首元素,让pplist指向它;如果链表里已经存储了数据,就不是空链表。这里我们先创建一个叫做tail的结构体指针变量,让它指向首元素,接着再一个一个找链表的尾元素,找到尾元素后再把newnode插入到链表后面就完成了。
图示:
3.1.2.4.头插数据
void SListPushFront(SListNode** pplist, SLTDataType x)
{
assert(pplist);
SListNode* newnode = BuySListNode(x);
newnode->next = *pplist;
*pplist = newnode;
}
头插就非常简单了,我们只需要把newnode插入到链表的最前面,也不用考虑什么特殊情况。
图示:
3.1.2.5.尾删链表
void SListPopBack(SListNode** pplist)
{
assert(*pplist);
//一个节点
if ((*pplist)->next == NULL)
{
free(*pplist);
*pplist = NULL;
}
else
{
//找尾
SListNode* tail = *pplist;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
尾删数据时,必须要考虑链表只有一个节点的情况,如果只有一个节点,那就直接释放这块空间就可以了。如果有一个以上的节点,我们这里先创建一个tail结构体指针变量来找尾,但是注意这里我们不能让tail走到NULL的前一个,否则需要删除的节点就找不到了。这里我们循环判断tail指向的下一个节点的下一个节点是不是空。如果不为空,就让tail走到下一个节点;如果为空,那么tail指向的下一个节点正好是需要删除的节点。释放掉尾节点的空间后,记得要让把tail指向的下一个节点制空(tail->next=NULL)
图示:
3.1.2.6.头删数据
void SListPopFront(SListNode** pplist)
{
assert(*pplist);
SListNode* tmp = *pplist;
*pplist = (*pplist)->next;
free(tmp);
}
头删数据也很简单,我们只需要让首元素变成第二个节点,再把第一个节点的空间释放掉就可以了(这里不再给图示)
3.1.2.7.查找数据
SListNode* SListFind(SListNode* plist, SLTDataType x)
{
SListNode* cur = plist;
while (cur)
{
if (cur->data == x)
{
return cur;
}
else
{
cur = cur->next;
}
}
return NULL;
}
查找数据的逻辑是先在链表中逐个查找各个节点的值是不是等于我们传进来的x,如果有一个节点等于x,就把这个节点作为返回值返回。如果找不到这个值,就返回空指针。(注意,这个实现方法不能找到多个值相同的节点)
(这里的逻辑与打印列表相似,所以也不画图了。)
3.1.2.8.插入数据
void SListInsertAfter( SListNode* pos, SLTDataType x)
{
assert(pos);
SListNode* newnode = BuySListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
这里的插入数据必须要先使用上一个SListFind函数查找到需要插入的节点位置(pos),这也是上一个函数把节点指针作为返回值的原因,并且这里会用assert断言判断pos是否有效,在SListFind函数中我们设置了找不到就返回空指针,若我们把找不到的位置传入这个函数就会报错提示。注意这里实现插入的两条语句顺序不能颠倒,否则newnode之后的节点会全部丢失。
3.1.2.9.删除查找位置
void SLTErase(SListNode** pplist, SListNode* pos)
{
assert(pplist);
assert(*pplist);
assert(pos);
if (*pplist == pos)
{
SListPopFront(pplist);
}
else
{
SListNode* prev = *pplist;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
在进行删除操作之前,我们必须判断是不是空链表,pos的位置是否有效。如果是链表只有一个节点,那么这里的删除逻辑与头删相同,只需要插入一个头删函数就可以了。如果有两个及以上的节点,我们需要设置一个找pos前一个位置的结构体指针变量prev,循环判断找到pos的前一个位置后,先将prev指向的节点与pos所指向的后一个节点连接起来,才能释放pos所指向的空间,否则pos之后的节点会全部丢失。
(中间删除与中间插入类似,这里也不给图示了。)
3.1.2.x.销毁链表
void SLTDestroy(SListNode** pplist)
{
//1.空链表
if (pplist == NULL || *pplist == NULL)
{
return;
}
SListNode* current = *pplist;
SListNode* next;
//2.不为空
while (current != NULL)
{
next = current->next;// 保存下一个节点的指针
free(current); // 释放当前节点的内存
current = next; // 将当前指针移动到下一个节点
}
*pplist = NULL; // 将链表头指针设为 NULL,表示链表已销毁
}
当我们使用完链表不需要时,需要释放整个链表开辟的空间。当然如果你的程序已经走向结束,其实不主动销毁链表,内存也会自行回收这块空间。但如果我们的链表是在24小时不间断运行的服务器上使用,销毁链表的操作就尤为重要了。
如果链表本身就是空链表,那么也就不用去销毁他了,如果不是空链表那么只需要逐个节点释放开辟的空间就可以了。
(图示与之前的类似,就不画了。)
3.2. 带头单链表
在上面单链表的实现过程中,我们需要一个二级指针来对链表进行操作,这是因为头节点也是链表存储有效数据的节点。但是如果我们使用一个不存储有效数据的节点作为头节点,也叫哨兵位(站岗,很形象),我们不对这个头节点进行任何增删查改的操作,那么也就不需要一个二级指针来传地了。
带头单链表可以用普通的单链表追加实现,也可以单独实现。这里因为篇幅原因不在本篇博客实现。在我写的另一篇关于双向链表的博客中使用了不存储有效数据的节点作为头节点,可以点击下方链接参考一下:
4.单链表的优缺点
4.1.优点:
动态大小:
链表的大小可以动态调整,不像数组需要预先指定大小。这使得链表在需要频繁插入或删除元素的情况下更为灵活。
内存管理:
链表在内存分配方面较为灵活。它可以根据需要动态分配和释放内存,而不像数组一样需要一块连续的内存空间。
插入和删除效率高:
在链表中插入和删除元素的效率通常比数组高,特别是在中间插入或删除元素,因为不需要移动其他元素。
4.2.缺点:
随机访问低效:
链表的随机访问效率较低,因为要找到特定位置的元素需要从头开始逐个遍历,而数组可以通过索引直接访问。
额外的空间:
链表需要额外的空间来存储指针(引用),这会增加存储开销。相比之下,数组只需要存储元素的值。
缓存局部性差:
由于链表中的元素在内存中不一定是连续存储的,这可能导致缓存局部性较差,从而影响访问速度。
不适合小规模数据:
对于小规模的数据集,链表可能由于存储指针的额外开销而不如数组高效。
5.完整代码在这里
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
// slist.h
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SListNode;
// 动态申请一个节点
SListNode* BuySListNode(SLTDataType x);
// 单链表打印
void SListPrint(SListNode* plist);
// 单链表尾插
void SListPushBack(SListNode** pplist, SLTDataType x);
// 单链表的头插
void SListPushFront(SListNode** pplist, SLTDataType x);
// 单链表的尾删
void SListPopBack(SListNode** pplist);
// 单链表头删
void SListPopFront(SListNode** pplist);
// 单链表查找
SListNode* SListFind(SListNode* plist, SLTDataType x);
// 单链表在pos位置之后插入x
void SListInsertAfter( SListNode* pos, SLTDataType x);
// 删除pos位置
void SLTErase(SListNode** pphead, SListNode* pos);
//销毁链表
void SLTDestroy(SListNode** pplist);
SListNode* BuySListNode(SLTDataType x)
{
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
void SListPrint(SListNode* plist)
{
SListNode* cur = plist;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
void SListPushBack(SListNode** pplist, SLTDataType x)
{
SListNode* newnode = BuySListNode(x);
if (*pplist == NULL)
{
*pplist = newnode;
}
else
{
//找尾
SListNode* tail = *pplist;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
void SListPushFront(SListNode** pplist, SLTDataType x)
{
assert(pplist);
SListNode* newnode = BuySListNode(x);
newnode->next = *pplist;
*pplist = newnode;
}
void SListPopBack(SListNode** pplist)
{
assert(*pplist);
//一个节点
if ((*pplist)->next == NULL)
{
free(*pplist);
*pplist = NULL;
}
else
{
//找尾
SListNode* tail = *pplist;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
void SListPopFront(SListNode** pplist)
{
assert(*pplist);
SListNode* tmp = *pplist;
*pplist = (*pplist)->next;
free(tmp);
}
SListNode* SListFind(SListNode* plist, SLTDataType x)
{
SListNode* cur = plist;
while (cur)
{
if (cur->data == x)
{
return cur;
}
else
{
cur = cur->next;
}
}
return NULL;
}
void SListInsertAfter( SListNode* pos, SLTDataType x)
{
assert(pos);
SListNode* newnode = BuySListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
void SLTDestroy(SListNode** pplist)
{
//1.空链表
if (pplist == NULL || *pplist == NULL)
{
return;
}
SListNode* current = *pplist;
SListNode* next;
//2.不为空
while (current != NULL)
{
next = current->next;// 保存下一个节点的指针
free(current); // 释放当前节点的内存
current = next; // 将当前指针移动到下一个节点
}
*pplist = NULL; // 将链表头指针设为 NULL,表示链表已销毁
}
// 删除pos位置
void SLTErase(SListNode** pplist, SListNode* pos)
{
assert(pplist);
assert(*pplist);
assert(pos);
if (*pplist == pos)
{
SListPopFront(pplist);
}
else
{
SListNode* prev = *pplist;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}