1. 顺序表问题与思考
上一篇博客中我们介绍了顺序表,那么顺序表有什么缺点呢?
- 中间 / 头部的插入删除,时间复杂度为O(N)
- 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
- 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
如何解决以上问题呢? 我们可以使用单链表。
2.单链表
2. 1 概念与结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表就像一个火车:
淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉加上,不会影响其他车厢,每节车厢都是独立存在的。
在链表里,每节“车厢”是什么样的呢?
2.1.1 结点
与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,我们称之为“结点”。结点的组成主要有两个部分:当前结点要保存的数据和保存下一个结点的地址(指针变量)。图中指针变量 plist
保存的是第一个结点的地址,我们称plist
此时指向第一个结点,如果我们希望plist
指向第二个结点时,只需要修改plist
保存的内容为0x0012FFA0
。链表中每个结点都是独立申请的(即需要插入数据时才去申请一块结点的空间),我们需要通过指针变量来保存下一个结点位置才能从当前结点找到下一个结点。
2.1.2 链表的性质
- 链式机构在逻辑上是连续的,在物理结构上不一定连续
- 结点一般是从堆上申请的
- 从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续
结合前面学到的结构体和顺序表知识,我们可以给出每个结点对应的结构体代码:
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data; //用于存储数据
struct SListNode* next; //指向下一个节点
}SLTNode;
当我们想要保存一个整型数据时,实际是向操作系统申请了一块内存**,这块内存不仅要保存整型数据,也需要保存下一个结点的地址**(当下一个结点为空时保存的地址为空) 当我们想要从第一个结点走到最后一个结点时,只需要在当前结点拿上下一个结点的地址就可以了。
3. 单链表实现
不带头单向不循环链表
记得进行良好的文件管理。
3. 1 单链表初始化
实际上,单链表并不需要一个用于初始化的函数,这是由单链表的性质决定的。在使用单链表时,需要在 main
函数中创建一个 SLTNode*
的变量,再将它的地址传递给其他函数就可以了。
为什么在最开始要创建一个指针变量?这个问题在后面头插函数中解释。
3. 2 单链表的打印
单链表的底层结构不是数组了,那我们应该怎样进行打印呢?或者说,我们应该怎么遍历单链表?
void SListPrint(SListNode* plist); //phead是在main函数中创建的变量,是单链表的头
我们可以用一个SLTNode*
变量 pcur 来遍历数组,在打印了pcur->data
之后,让pcur
变为pcur->next
,直到cur==NULL
结束,就可以了。
void SListPrint(SListNode* plist)
{
SListNode* cur = plist;
while (cur)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
如图所示:
3. 3 头插
void SListPushFront(SListNode** pplist, SLTDateType x); //注意第一个参数是二级指针
要完成头插,我们需要完成几个步骤:
- 创建新节点
- 将头结点的
next
指针指向原来的头结点 - 将头结点换成这个新插入的节点
首先第一步创建新节点,这个步骤在其他的插入函数中也会用到,所以我们先跳过,稍后单独封装一个函数BuyListNode
方便使用。假设我们得到了新节点newnode
,完成第二步就很简单了,只需要newnode->next=*pphead
就可以了。第三步,pphead
是指向头结点的指针,因此要修改头结点,只需要修改*pphead
就可以了(这也就是为什么要传递二级指针)
参考代码:
void SListPushFront(SListNode** pplist, SLTDateType x)
{
assert(pplist); //在函数解引用之前断言它不是空指针
SListNode* newnode = BuySListNode(x);
if (*pplist == NULL)
*pplist = newnode;
else
{
newnode->next = *pplist;
*pplist = newnode;
}
}
值得注意的是,最后两步的顺序绝对不可以颠倒!
3. 4 创建新节点
创建新节点一般是在插入时调用的,因此将存储数据这一步直接放在函数内部会更方便使用。
SLTNode* BuyListNode(SLTDataType x);
这个函数用来动态开辟一个新节点,注意一定要是动态申请的,不然创建的节点会在函数调用结束后随着函数栈帧一起销毁。
SLTNode* BuyListNode(SLTDataType x)//创建新节点
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (NewNode == NULL)
{
printf("malloc failed");
exit(-1); //检查malloc是否成功,若失败则报错退出
}
newnode->data = x;
newnode->next = NULL; //新的节点的next置为NULL,方便使用
return newnode;
}
除了上面这样使用返回值传递新节点,你当然也可以使用二级指针来传递新节点,就像这样:
void BuyListNode(SLTNode** ppnewnode,SLTDataType x);
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
printf("malloc failed");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
*ppnewnode = newnode;
}
但是这样的话就要在函数调用之前创建SLTNode*
类型的变量,不方便使用,所以不推荐这样写。
3. 5 尾插
void SListPushBack(SListNode** pplist, SLTDateType x);
相较于头插,尾插多了一个步骤:找到单链表现在的尾节点。要完成头插,我们需要完成几个步骤:
- 创建新节点
- 找到尾节点
- 将尾结点的
next
指针指向新节点
我们重点来看第二步,在单链表中,并没有存储尾节点,但是我们可以通过遍历的方式找到尾节点,就像:
SListNode* cur = *pplist;
while (cur->next)
cur = cur->next;
最终退出循环时,cur
就是尾节点了。
当然,值得注意的是,如果此时单链表为空也就是*plist==NULL
的时候就不能按第三步进行了,否则会发生空指针的解引用,要单独处理这一情况,将这个新节点变成头结点。
参考代码:
void SListPushBack(SListNode** pplist, SLTDateType x)
{
assert(pplist);
SListNode* newnode = BuySListNode(x);
if (*pplist == NULL)
*pplist = newnode; //如果链表为空,将新节点作为头结点
else
{
SListNode* cur = *pplist;
while (cur->next)
cur = cur->next; //找到尾节点
cur->next = newnode;
}
}
3. 6 头删和尾删
头删相对简单,分为两个步骤:
- 将原来的头结点存储起来,并将下一个节点作为头结点。
- 释放原来的头结点,并将指向原来的头结点的指针置空。
我们来分析一下,要不要对单链表中只有一个节点的情况特殊处理:
很显然,不需要,因为头结点的下一个节点就是NULL
,那么头结点也就变成了空结点。参考代码:
void SListPopFront(SListNode** pplist)
{
assert(pplist && *pplist);
SListNode* tmp = *pplist;
*pplist = (*pplist)->next;
free(tmp);
tmp = NULL;
}
再来看尾删,尾删麻烦一点,分为几个步骤:
- 找到尾节点和它的上一个节点。
- 释放尾节点。
- 将现在的尾节点的
next
指针置为空。
除此之外,我们也来分析一下如果链表中只有一个节点的情况:不需要过多分析我们就能发现:在第一步的时候,就无法找到头结点的上一个节点,因此这种情况显然要单独处理:
if ((*pplist)->next == NULL)
{
SListNode* tmp = *pplist;
free(tmp); //不要忘记释放节点,避免内存泄漏
tmp = NULL;
*pplist = NULL;
}
那么尾删的代码也就很容易写出来了:
void SListPopBack(SListNode** pplist)
{
assert(pplist && *pplist);
if ((*pplist)->next == NULL)
{
SListNode* tmp = *pplist;
free(tmp);
tmp = NULL;
*pplist = NULL;
}
else
{
SListNode* cur = *pplist;
SListNode* pre = NULL;
while (cur->next) //循环会在cur为尾节点时停止
{
pre = cur;
cur = cur->next;
}
free(cur);
cur = NULL;
pre->next = NULL; //如果不进行这一步,尾节点就会指向野指针
}
}
3. 7 查找
SListNode* SListFind(SListNode* plist, SLTDateType x);
//找到存储了数据x的节点,并返回节点没找到就返回NULL
这个是为了后面的删除和插入做前置工作。这个函数只需要遍历一遍链表并一一比较就可以了:
SListNode* SListFind(SListNode* plist, SLTDateType x)
{
assert(plist);
SListNode* cur = plist;
while (cur)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
3. 8 在指定位置之后插入或删除
先来看插入:
void SListInsertAfter(SListNode* pos, SLTDateType x);
插入有几个步骤:
- 申请新节点
- 将新节点的
next
指针指向pos->next
- 将
pos->next
指向新节点
这个pos
来自上面的查找函数,也就是说,如果pos
不为空,那么pos
是一定在链表中的,不需要额外检查,但需要检查它是否为空。特别要注意的是:第二步和第三步的顺序绝对不能颠倒,不然就会找不到原来的下一个节点。
void SListInsertAfter(SListNode* pos, SLTDateType x)
{
assert(pos);
SListNode* newnode = BuySListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
再来看删除:
void SListEraseAfter(SListNode* pos)
删除分为几个步骤:
- 将pos->next指向pos->next->next
- 释放pos->next
当然,pos
也需要进行判空操作,pos->next
也需要,要调用这个函数,这两者应该都不能为空。
void SListEraseAfter(SListNode* pos)
{
assert(pos && pos->next);
SListNode* tmp = pos->next;
pos->next = tmp->next;
free(tmp);
tmp = NULL;
}
3. 9 在指定位置前面插入或删除指定位置
需要注意的是,在C++的STL库(数据结构库)中,并没有这样的两个函数,至于原因会在最后讲。
先来看插入:
void SListInsert(SListNode** pphead, SListNode* pos, SLTDateType x);
分为几个步骤:
- 遍历单链表,找到
pos
的上一个节点 - 申请新节点
- 将新节点按在指定位置之后插入的方式插入到
pos
的上一个节点后面
void SListInsert(SListNode** pphead, SListNode* pos, SLTDateType x)
{
assert(pphead && pos);
SListNode* newnode = BuySListNode(x);
SListNode* pre = *pphead;
while (pre->next != pos)
{
pre = pre->next; //遍历找到pos的上一个节点
}
pre->next = newnode; //剩下的便是在指定位置之后插入了
newnode->next = pos;
}
再来看删除:
void SListErase(SListNode** pphead, SListNode* pos)
分为几个步骤:
- 遍历单链表,找到
pos
的上一个节点 - 按在指定位置之后删除的方式删除
pos
不过值得注意的是:如果pos
是头结点的话,还需要将头结点改变一下,这也是为什么这个函数中头结点要传递二级指针。
void SListErase(SListNode** pphead, SListNode* pos)
{
assert(pphead && pos);
if (*pphead == pos) //处理pphead和pos重合的情况
{
*pphead = pos->next;
free(pos);
pos = NULL;
}
else
{
SListNode* pre = *pphead;
while (pre->next != pos)
{
pre = pre->next; //找到pos的上一个节点
}
pre->next = pos->next; //这后面的就是在指定位置后面删除节点
free(pos);
pos = NULL;
}
}
为什么C++的STL库不提供这两个函数?我们可以计算一下这两个函数以及上面的向后插入删除的函数的时间复杂度,可以发现,向后插入删除的函数的时间复杂度都是O(1),而下面这两个函数的时间复杂度都是O(N),因为单链表不能直接找到节点的上一个节点,所以单链表并不适合向前插入和原地删除的函数,有其他的链表可以更好地完成这样的任务。
3. 10 销毁
void SListDestroy(SListNode** pphead)
在单链表使用结束后,一定要记得销毁单链表,不然会发生内存泄漏,可能导致程序崩溃。单链表的销毁比顺序表麻烦一些,因为单链表的空间是一块一块地申请的,所以也要一块一块地释放。 只需要遍历单链表, 逐个释放就可以了。
void SListDestroy(SListNode** pphead)
{
assert(pphead);
SListNode* cur = *pphead;
while (cur)
{
SListNode* tmp = cur->next;
free(cur);
cur = tmp; //遍历着往下走
}
*pphead = NULL; //还要记得将头结点置空,不然就是一个野指针
}
谢谢你的阅读,喜欢的话来个点赞收藏评论关注吧!我会持续更新更多优质文章
标签:结点,单链,cur,pos,next,初阶,SListNode,数据结构,节点 From: https://blog.51cto.com/u_17049566/12364302