本文已参与「新人创作礼」活动,一起开启掘金创作之路。
一、快速入门链表
①漫画图解
我们先通过一个现实的例子来说明链表的特性和使用:
- 总结
这种存储数据的方式就是链表,很简单不是吗。我们只需要定义一个结构体包含以下两个部分:数据域
和指针域
。数据域就像是我们存放的随身物品,指针域存放下一件抽屉的地址。这样我们就可以从第一个抽屉找起,直到找到最后一个抽屉为止。 显然,最后一间抽屉里不存放下一件抽屉的地址(本来就没有的嘛)
typedef int SListData; //为了方便修改链表中数据的类型
typedef struct SListNode
{
SListData val; //每个结点的值
struct SListNode* next; //存放下一结点地址。注意这里的struct不要省略啦
}SListNode;
②与顺序表的区别
- 顺序表内的各个元素在内存中必须连续,而链表并不需要
- 顺序表的插入、删除速度:O(n) ; 链表的插入、删除速度:O(1)
- 顺序表的读取速度:O(1) ; 链表的读取速度O(n)
我们再来联想这样一个问题:我们写一个图书管理系统,那么对于线性表来说存储空间开多大才是合适的呢?开大了空间浪费,开小了频繁扩容效率低下。然而对于链表就不存在这样的问题,需要一个结点就申请一个结点,完全不会有空间浪费。链表也有其独特的魅力。
相信大家通过上一章的学习对顺序表的读取和插入有了深刻的理解。对链表的读取插入的速度会通过接下来相应接口的实现来体悟,需要注意的是,当且仅当能够立刻访问要插入的位置或删除的位置时,速度才是O(1),因为定位也需要时间。
二、链表接口的实现
①准备函数
我们先来写一些函数来方便我们之后对于链表的操作: 1.BuyListNode函数:生成一个结点
SListNode* BuyListNode(SListData x)
{
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL) //注意检查是否成功生成
{
printf("结点创建失败创建失败\n");
exit(1);
}
else
{
newnode->val = x;
newnode->next = NULL;
}
return newnode;
}
我们可以生成很多个结点,只不过他们都是孤立的没有串联起来。 2.SListPrint函数:打印结点
void SListPrint(SListNode* phead)
{
while (phead)
{
printf("%d ", phead->val);
phead = phead->next;
}
printf("NULL\n");
}
这个函数本身对于链表操作没有任何作用,只不过打印出各个节点方便我们检查是否达到了我们预期的目标
②尾插和头插
1.尾插
void SlistPushBack(SListNode** pphead, SListData x)
//注意传参要传二级指针,从栈帧角度加以说明
{
assert(pphead);
SListNode* newnode = BuyListNode(x);
if (*pphead == NULL)//*pphead为空,说明没有任何节点,直接赋值即可
{
*pphead = newnode; //*pphead需要被修改,所以传二级指针
}
else
{
SListNode* tail = *pphead;
while (tail->next) //注意检查的不是tail而是tail的后一位
{
tail = tail->next;
}
tail->next = newnode;
}
}
看似简单的代码还是有很多可以思考的地方:
①为什么要传入二级指针
我们从函数栈帧的角度来理解,想了解更多关于栈帧知识的朋友可以看这篇博客:【C语言知识精讲③】函数栈帧的创建和销毁(全程图解) 如果传入的是一级指针phead,根据函数栈帧的知识我们知道,phead是头节点的临时拷贝,phead内存放头节点的地址值,但两者是独立的,我们对phead重新赋值(如接收malloc返回的地址值),是不会改变头节点的。这也就是C语言中经典的传值调用错误
,所以我们需要传值调用,即传入phead的地址,所以应该传二级指针。
②将上面代码进行如下修改,为什么会尾插个寂寞?
SListNode* tail = *pphead;
while (tail != NULL)
{
tail = tail->next;
}
tail = newnode;
tail虽然存储
的是链表最后一个节点的地址,但是tail和最后一个节点是独立
的,tail = newnode 只不过是对tail进行重新赋值,但那和最后一个节点有什么关系呢?
- 小结
1.传二级指针主要是针对修改头节点的情况(如开始什么节点也没有),其他节点通过plist->next找到的就是相应的目标节点非临时拷贝
2.结构体传址调用更加节省空间(指针大小固定为4/8字节)
- 小结
- 找到最后一个节点
- 创建一个新节点
- 让最后一个节点的next指向新节点
2.头插
void SListPushFront(SListNode** pphead, SListData x)
{
assert(pphead);
SListNode* newnode = BuyListNode(x);
if (*pphead == NULL)//头部没有元素时直接插入
{
*pphead = newnode;
}
else
{
newnode->next = *pphead;
*pphead = newnode;
}
}
- 小结
- 创建新节点
- 让新节点的next指向头节点
①头插和尾插的对比
可以看出头插非常方便,因为尾插首先要遍历到最后一个节点才进行插入操作。但插入的过程的时间复杂度都是O(1)
③头删和尾删
1.尾删
void SListPopBack(SListNode** pphead) //对第一个节点做出修改就需要二级指针
{
assert(pphead);
if (*pphead == NULL) //没有一个节点删个寂寞,直接return
{
return;
}
else if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SListNode* tail = *pphead;
while (tail->next->next) //找到最后一个节点为止
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
else内也可以用双指针法处理
//双指针法
SListNode* pre = (*pphead)->next;
SListNode* cur = *pphead;
while (pre->next)
{
cur = pre;
pre = pre->next;
}
free(cur->next);
cur->next = NULL;
①将else内的语句改成如下图所示,为什么错误?
//错误示范
void SListPopBack(SListNode* pphead)
{
assert(pphead);
SListNode* tail = pphead;
while (tail->next)
{
tail = tail->next;
}
free(tail);
tail = NULL;
}
同之前反复提到的错误,tail和链表节点没有一点关系,free一个不是malloc出来的空间程序会崩溃。
- 小结
- 遍历链表找到最后一个节点
- 将其free(),其指针置为NULL
2.头删
void SListPopFront(SListNode** pphead)
{
assert(pphead);
if (*pphead == NULL) //没有节点则直接返回
{
return;
}
else
{
SListNode* tmp = (*pphead)->next;
free(*pphead);
*pphead = tmp;
}
}
- 小结
- 保存下一节点next
- 删除头节点
- 将头节点赋值为next
④查找
SListNode* SListFind(SListNode* phead, SListData x)
{
assert(phead);
SListNode* cur = phead;
while (cur)
{
if (cur->val == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
很简单,常与中间插入中间删除配合使用
⑤向后插入向后删除
1.向后插入
void SListInsertAfter(SListNode* pos, SListData x)
{
assert(pos);
SListNode* newnode = BuyListNode(x);
SListNode* next = pos->next;
pos->next = newnode;
pos->next->next = next;
}
- 小结
- 先通过SListFind找到待插入地址pos(main函数中实现)
- 创建新的节点
- 将新的节点接入链表(接入方式见上图)
⑥向前插入向前删除
void SListEraseAfter(SListNode* pos)
{
SListNode* cur = pos;
if (cur->next)
{
SListNode* next = pos->next->next;
free(pos->next);
pos->next = NULL;
pos->next = next;
}
}
- 小结
- 通过SListFind找到pos位置(main函数中实现)
- 记录pos位置下下节点地址
- 将pos节点的下一节点删除(删除方式见上图)
⑦向前插入pos节点处删除
在C++中有提供链表向后插入和向后删除的函数,为什么不提供向前的呢?因为对于单向链表进行向前的操作需要再次从头开始遍历找到前一节点才行,这是很低效的。但是为了加深大家的理解,还是向大家呈现相应代码: 1.向前插入
void SListInsert(SListNode** pphead, SListNode* pos, SListData x)
{
assert(pphead);
assert(pos);
if (pos == *pphead) //若在头部前插等效与头插,直接调用之前所用函数
{
SListPushFront(pphead, x);
}
else
{
SListNode* pre = *pphead;
SListNode* newnode = BuyListNode(x);
while (pre->next != pos) //遍历找打pos前一节点
{
pre = pre->next;
}
pre->next = newnode;
newnode->next = pos;
}
}
2.pos节点处删除
void SListErase(SListNode** pphead, SListNode* pos)
{
assert(pphead);
if (pos == *pphead)
{
SListPopFront(pphead);
}
else
{
SListNode* cur = *pphead;
while (cur->next != pos)
{
cur = cur->next;
}
cur->next = pos->next;
free(pos);
}
}
三、链表小结
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
- 单向或双向链表
- 带头或不带头链表
- 循环和不循环链表
- 最常用的两个:无头双向非循环链表、带头双向循环链表
- 全文总结
- 单链表适合头插头删,尾部或者中间插入删除不适合
- 如果要使用链表单独存储数据,那我们后面学习双向链表更合适
- 学习单链表的意义在于:①单链表作为我们以后学习复杂数据结构的子结构(图的邻接表、哈希桶)②与单链表有关的经典练习题很多