参考书籍: 《算法图解》提取码:s48k 《代码随想录》在线资源
动图绘制:数据结构可视化网站、ScreenToGif 提取码:3o9y
适用对象:初学链表者或者缺乏链表刷题经验者
专栏宣传:算法入门系列长期更新,欢迎点赞收藏,祝大家学有所获。
在上一篇博客里,我们学习了顺序表,了解了顺序表的常用刷题方法,这次我们我们学习同为线性表之一的单链表。单链表(无头+单向+非循环)作为最简单的链表形式,虽然缺陷也比较显著,但是也有它存在的必要。现在开始我们详细的介绍。虽然文章看起来很长,但主要都是代码,所以耐心看下去吧。
这种存储数据的方式就是链表,很简单不是吗。我们只需要定义一个结构体包含以下两个部分:数据域
和指针域
。数据域就像是我们存放的随身物品,指针域存放下一件抽屉的地址。这样我们就可以从第一个抽屉找起,直到找到最后一个抽屉为止。
显然,最后一间抽屉里不存放下一件抽屉的地址(本来就没有的嘛)
typedef int SListData; //为了方便修改链表中数据的类型
typedef struct SListNode
{
SListData val; //每个结点的值
struct SListNode* next; //存放下一结点地址。注意这里的struct不要省略啦
}SListNode;
我们再来联想这样一个问题:我们写一个图书管理系统,那么对于线性表来说存储空间开多大才是合适的呢?开大了空间浪费,开小了频繁扩容效率低下。然而对于链表就不存在这样的问题,需要一个结点就申请一个结点,完全不会有空间浪费。链表也有其独特的魅力。
相信大家通过上一章的学习对顺序表的读取和插入有了深刻的理解。对链表的读取插入的速度会通过接下来相应接口的实现来体悟,需要注意的是,当且仅当能够立刻访问要插入的位置或删除的位置时,速度才是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.头插
void SListPushFront(SListNode** pphead, SListData x)
{
assert(pphead);
SListNode* newnode = BuyListNode(x);
if (*pphead == NULL)//头部没有元素时直接插入
{
*pphead = newnode;
}
else
{
newnode->next = *pphead;
*pphead = newnode;
}
}
①头插和尾插的对比
可以看出头插非常方便,因为尾插首先要遍历到最后一个节点才进行插入操作。但插入的过程的时间复杂度都是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出来的空间程序会崩溃。
2.头删
void SListPopFront(SListNode** pphead)
{
assert(pphead);
if (*pphead == NULL) //没有节点则直接返回
{
return;
}
else
{
SListNode* tmp = (*pphead)->next;
free(*pphead);
*pphead = tmp;
}
}
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;
}
void SListEraseAfter(SListNode* pos)
{
SListNode* cur = pos;
if (cur->next)
{
SListNode* next = pos->next->next;
free(pos->next);
pos->next = NULL;
pos->next = next;
}
}
在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种链表结构:
本来还打算在最后直接跟上Leetcode刷题技法,但是由于文章篇幅太长,我们就下期再见吧。觉得不错就请点赞关注吧。