前言
hello,大家好呀,我是Humble 在之前的两篇博客,我们学完了数据结构中的顺序表,还对它进行了一个应用,做了一个通讯录的小项目
那今天我们再来学习一个新的数据结构——链表
引入
我们来回忆一下顺序表
对于顺序表,我们发现它有下面的这些问题
1.中间/头部的插入删除,时间复杂度为O(N)
2.增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗
3.增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到
200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间
思考:如何解决以上问题呢?有没有以一种数据结构,它可以解决顺序表的这些问题呢?
这就是我们今天要讲的链表了
链表的概念及结构
链表在物理存储结构上是非连续、非顺序的存储的、
其数据元素的逻辑顺序是通过链表中的指针链接次序实现的
而与顺序表不同的是,链表是由节点组成的
节点的组成主要有两个部分:
1.当前节点要保存的数据
2.保存下一个节点的地址(指针变量)
变量来保存下一个节点位置才能从当前节点找到下一个节点
结合结构体的知识,我们可以给出每个节点对应的结构体代码:
struct SListNode
{
int data; //节点数据,我们假设当前保存的节点为整型
struct SListNode* next; //指针变量⽤保存下⼀个节点的地址
};
当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存,这个内存不仅要保存整型数
据,也需要保存下一个节点的地址
所以,当我们想要从第一个节点走到最后一个节点时,只需要在前一个节点拿上下一 个节点的地址就可以了(有点绕,请耐心理解哦)
那么,给定的链表结构中,我们来实现一下节点从头到尾的打印吧~
我们在创建一个SList 的工程表示单链表
然后创建3个文件,分别是我们的SList.h 头文件 ,SList.c源文件以及测试文件test.c
(这个大家应该已经很熟悉了吧)
在三个文件中,我们分别去实现各自的职能
SList.h
#pragma once
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
void SLTPrint(SLTNode* phead);//打印
SList.c
#include"SList.h"
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
test.c
#include "SList.h"
void SlistTest01() {
//一般我们不会这样去创建链表,这里只是为了给大家展示链表的打印
SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));
node1->data = 1;
SLTNode* node2 = (SLTNode*)malloc(sizeof(SLTNode));
node2->data = 2;
SLTNode* node3 = (SLTNode*)malloc(sizeof(SLTNode));
node3->data = 3;
SLTNode* node4 = (SLTNode*)malloc(sizeof(SLTNode));
node4->data = 4;
node1->next = node2;
node2->next = node3;
node3->next = node4;
node4->next = NULL;
SLTNode* plist = node1;
SLTPrint(plist); //打印1->2->3->4->NULL
}
int main()
{
SlistTest01();
return 0;
}
我们来测试一下,按照我们的想法,应该打印1->2->3->4->NULL
运行结果:
单链表的实现
找到了链表的打印,我们就来实现链表的各个功能吧
链表的尾插
这要分两种情况来讨论
1.链表不为空
2.链表为空
先画张图来辅助理解一下:
假设我们要在链表插入 元素4
下面我们来写尾插STLPushBack的代码:
void SLTPushBack(SLTNode** pphead, SLTDataType x) //注意这里pphead是二级指针,用**
{
assert(pphead);
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
//链表为空,新节点作为phead
if (*pphead == NULL) {
*pphead = newnode;
return;
}
//链表不为空,找尾节点
SLTNode* ptail = *pphead;
while ((ptail->next) != NULL) //遍历
{
ptail = ptail->next;
}
//遍历完之后ptail就是尾节点
ptail->next = newnode; //完成尾插
}
下面我们来测试一下
我们在test.c中这样写:
void SlistTest02()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1); //我们要把plist指针的地址传过去,这个很重要!
SLTPrint(plist); //预计结果1->NULL
}
int main()
{
SlistTest02();
return 0;
}
运行一下:
当然,因为我们下面的操作都要设计申请节点,每次都要写:
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
我们干脆就再写一个函数,之后直接调用就行
这样代码就会变成这样
SLTNode* SLTBuyNode(SLTDataType x) //申请新节点
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
return newnode;
}
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
//链表为空,新节点作为phead
if (*pphead == NULL) {
*pphead = newnode;
return;
}
//链表不为空,找尾节点
SLTNode* ptail = *pphead;
while (ptail->next)
{
ptail = ptail->next;
}
//ptail就是尾节点
ptail->next = newnode;
}
接下来我们来看一下头插SLTPushFront:
它同样分2种情况,但它们的代码是一样的,所以就不用分了
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
测试一下:
void SlistTest02()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPushFront(&plist, 5);
SLTPushFront(&plist, 6);
SLTPushFront(&plist, 7);
SLTPrint(plist); //期望结果为:7->6->5->1->2->3->4->NULL
}
int main()
{
SlistTest02();
return 0;
}
运行结果如下:
接下来看一下尾部删除SLTPopBack吧~
既然要删除,我们要保证链表不为空,所以相比前面的这几种操作,它还要加上
assert(*pphead);//表示链表不能为空
此外,要分链表是否只有一个节点,即是否有前驱节点这2中情况
void SLTPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);//保证链表不能为空
//链表只有一个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
return;
}
//链表有多个节点
SLTNode* ptail = *pphead;
SLTNode* prev = NULL;
while ((ptail->next)!=NULL)
{
prev = ptail;
ptail = ptail->next;
}
prev->next = NULL;
//销毁尾结点
free(ptail);
ptail = NULL;
}
我们也来测试一下:
void SlistTest02()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPopBack(&plist);
SLTPrint(plist); //预期结果为1->2->3->NULL
}
int main()
{
SlistTest02();
return 0;
}
运行结果如下:
接下来看一下头部删除SLTPopFront吧~
这个也很简单,我们直接上代码~
//头删
void SLTPopFront(SLTNode** pphead)
{
assert(pphead);
//链表不能为空
assert(*pphead);
//让第二个节点成为新的头
//把旧的头结点释放掉
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
接下来我们也是测试一下
void SlistTest03()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
//头删
SLTPopFront(&plist);
SLTPrint(plist); //2->3->4->NULL
SLTPopFront(&plist);
SLTPrint(plist); //3->4->NULL
}
int main()
{
SlistTest03();
return 0;
}
运行结果:
好,我们已经实现了头部和尾部的插入和删除的操作,接下来我们来实现一下查找的操作~
//查找
SLTNode* SLTFind(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
//遍历链表
SLTNode* pcur = *pphead;
while (pcur) //等价于pcur != NULL
{
if (pcur->data == x) {
return pcur;
}
pcur = pcur->next;
}
//没有找到
return NULL;
}
接下来测试一下:
void SlistTest03()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTNode* FindRet = SLTFind(&plist,1); //1 在链表中,可以找到
if (FindRet) {
printf("找到了!\n");
}
else {
printf("未找到!\n");
}
}
int main()
{
SlistTest03();
return 0;
}
运行结果:
接下来我们看一下在指定位置插入数据~
它分为2种,在指定位置之前插入和在指定位置之后插入数据
先看在指定位置之前插入数据吧
它要分要插入的位置是头节点和不是头节点2种情况讨论哦
实现代码如下:
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(*pphead);//链表 不能为空!
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
//pos刚好是头结点
if (pos == *pphead)
{
//头插
SLTPushFront(pphead, x);
return;
}
//pos不是头结点的情况
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = newnode;
newnode->next = pos;
}
好,我们来测试一下~
void SlistTest03()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTNode* FindRet = SLTFind(&plist,1);
SLTInsert(&plist, FindRet, 100);
SLTPrint(plist);//预期是100->1->2->3->4->NULL
}
int main()
{
SlistTest03();
return 0;
}
运行结果:
接下来我们再看一下在指定位置之后插入数据SLTInsertAfter吧~
这个实现起来要比在指定位置之前插入要简单
我们看代码:
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next; //特别注意一下这里的顺序哦~
pos->next = newnode;
}
写完后也测试一下:
void SlistTest03()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTNode* FindRet = SLTFind(&plist, 1);
SLTInsertAfter(FindRet, 100);
SLTPrint(plist);//预期是1->100->2->3->4->NULL
}
int main()
{
SlistTest03();
return 0;
}
测试一下:
那么,插入讲完了,我们接下来再看一下删除操作
分别是删除pos节点以及删除pos之后的节点
先看一下删除pos节点 的情况吧~
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
//pos刚好是头结点,没有前驱节点,执行头删
if (*pphead == pos) {
//头删
SLTPopFront(pphead);
return;
}
//pos不是头结点
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
下面来测试一下:
void SlistTest03()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTNode* FindRet = SLTFind(&plist, 4);
SLTErase(&plist, FindRet);
SLTPrint(plist);//预期是1->2->3->NULL
}
int main()
{
SlistTest03();
return 0;
}
运行结果:
再看一下删除pos之后的节点吧~
下面是实现的代码~
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
//pos->next不能为空
assert(pos->next);
SLTNode* del = pos->next; //定义一个中间的变量用来保存
pos->next = pos->next->next;
free(del);
del = NULL;
}
下面进行测试:
void SlistTest03()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTNode* FindRet = SLTFind(&plist, 2);
SLTEraseAfter(FindRet);
SLTPrint(plist);//预期是1->2->4->NULL
}
int main()
{
SlistTest03();
return 0;
}
好,最后我们来看一下链表的销毁操作吧~
//销毁链表
void SListDesTroy(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* pcur = *pphead; //pur依旧是作为临时变量,用于保存~
while (pcur)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
关于链表的销毁,我们可以通过调试来观察,这里就不再演示了,大家可以自己测试一下~
好,到这,我们就把单链表的实现给讲完了~(鼓掌鼓掌)
好,那么这里又出现了一个新的问题,我们在这里花了这么多精力说了单链表的各种操作,那么链表究竟有多少种类呢?它与单链表又是什么关系呢?
接下来,我们就来说说链表的分类
链表的分类
不知道大家有没有想过为什么我创建的这个工程名为SList?
其实它是Single Linked list 的简写,也就是单链表的意思
我们上面的对链表的各种插入,删除都是对单链表进行操作的
那其实 链表的种类有很多,单链表的全称就是不带头单向不循环链表
我们在平时为了方便就称为单链表了~
既然有不带头就有带头的,由单向也就有双向的,有不循环的也就有循环的
如此这般三三组合,其实就可以推出链表的种类有2*2*2=8种
各个种类的关系如图:
看到这么多种类的链表,大家也不要太焦虑,去想单单一种类型的单链表就学了这么久,更何况还有7种.....
其实,我们实际中最常用只有两种结构:单链表 和 带头双向循环链表(简称双向链表),后者我们会在之后的博客中进行介绍与分享的~
最后我们在来看一下单链表 和 双向链表各自的一些特点吧~
1.单链表(不带头单向不循环链表):结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等
这种结构也是在笔试面试中出现很多
2.双向链表(带头双向循环链表):结构最复杂,一般用在单独存储数据
实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,所以实现反而简单了,这个我们代码实现了就知道了,这里只要先大致有一个印象就行,不必担心~
结语
好了,今天关于链表的分享就到这里了
在学习编程的道路上Humble与各位同行,加油吧各位!
最后希望大家点个免费的赞或者关注吧(感谢感谢),也欢迎大家订阅我的专栏
让我们在接下来的时间里一起成长,一起进步吧!