目录
1、前言
在学习了顺序表之后,我们不难发现,顺序表除了具有使用起来简单方便的优点,还存在着一些缺点,比如:
1、空间不够的时候需要扩容。而扩容就需要一定的代价(尤其是在需要异地扩容的时候),同时还存在一定的空间浪费。
在给顺序表扩容的时候,通常都是在原有容量的基础上进行每次二倍的扩容,这样简化了扩 容操作的复杂度,不用每次进行插入操作都要进行一次扩容。但却很容易造成空间浪费的情 况出现。
2、 头部或者中部插入删除时,需要把该位置之后的所有数据都依次向后挪动,效率低下。
那么为了解决这些问题,有什么优化方案吗?
1、按需申请释放空间
2、不要挪动顺序
为了实现这些方案,我们引入了数据结构中的 链表 概念,接下来我们一起通过学习来更加深入、细致的了解链表的构建、增删查改以及销毁等操作。
2、单链表的实现
2.1、定义结构
为了满足功能需求,单链表节点的结构由两部分组成:
1、该链表节点内存储的数据
2、该链表节点指向下一个链表节点的指针
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
为了方便以后修改链表所储存数据的类型,我们使用 typedef 来定义数据类型。
2.2、节点的创建
大家来思考以下,我们在创建链表节点的时候使用这种创建方法可以吗?
void TestSList
{
SLTNode node;
}
答案是 不可以 的,这里涉及到c语言中非常重要的基础知识——局部变量在函数结束的时候会被销毁,这方面的内容我在之前写的一篇《函数栈帧的创建与销毁》中有过非常详细的解释,还不了解的同学可以先去学习一下。
不可以的原因是:我们在之后实现单链表的插入时,其本质就是把一个数据存放到这个单链表里,如果我们这样去定义一个节点,那么出了这个插入数据的函数,我们定义的节点就被自动销毁了,也就无法把数据存放到单链表之中。这是一个非常需要注意的地方。
那么为了让我们创建的这个节点出了作用域还依然存在,我们通过在 堆区 中申请一块空间来存放数据,具体操作如下:
STLNode* BuySLTNode(SLTDataType x)
{
STLNode* newnode = (STLNode*)malloc(sizeof(SLTNode));// malloc 在堆区上申请空间
if(newnode == NULL) //如果申请失败,那就出了大问题,直接终止程序
{
perror("malloc fail");
exit(-1);
}
newnode->data = x; //把数据存储在节点里
newnode->next = NULL;//把节点的指针初始化为空
return newnode; //返回我们在堆区上申请出的空间的指针
}
void TestSList()
{
STLNode* n1 = BuySLTNode(1);
STLNode* n2 = BuySLTNode(2);
}
通过以上操作,我们就可以在 堆区 上开辟出两块空间,分别为 n1 和 n2 。
通过这一段简单易懂的代码,相信同学们可以更好的理解单链表节点的创建是怎么样实现的。
2.3、单链表的创建
在创建单链表之前,大家有必要了解一下单链表的 物理结构 与 逻辑结构 :
上面我们说过,一个单链表的节点由两个成员组成,其中一个成员是要存储的数据,一个成员是指向下一个节点的指针
逻辑结构:想象出来的,方便理解的样子
物理结构:内存中实际存储的样子
单链表节点的指针成员中存放着下一个节点的地址,最后一个节点的指针成员里面存放0x00000000(即为NULL)。
接下来我们来构建一个n个节点的链表来体验一下:
SLTNode* CreateSList(int n)
{
SLTNode* phead = NULL;
SLTNode* ptail = phead;
int i = 0;
for(i = 0; i < n; i++)
{
SLTNode* newnode = BuySLTNode(i);
if(phead == NULL)
{
phead = newnode;
ptail = phead;
}
else
{
ptail->next = newnode;
ptail = ptail->next;
}
}
return phead;
}
最开始的时候 phead 与 ptail 都指向空,调用BuySLTNode函数在 堆区 创建新的节点,让 phead 与 ptail 都指向新节点。
当 phead 指向第一个节点之后,用 ptail 向后遍历,把所有创建的节点全部都连接起来,最后把 phead 作为头节点返回。
这样就是一个单链表的创建过程,同学们如果有需求,也可以在创建单链表节点的时候增加一个scanf函数,用来存储指定的数据,这里为了方便大家理解以及调试便捷不使用scanf。
2.4、单链表的打印
接下来我们可以写一个打印函数用来观察我们写的代码是否有效,创建的单链表是否正确,起到一个验证的作用。
void SLTPrint(SLTNode* phead)
{
SLTNode* cur = phead;//创建一个cur变量用来遍历 链表
while(cur) //当cur为空时,结束循环
{
printf("%d->", cur->data);
cur = cur->next; //遍历
}
printf("NULL\n");
}
我们创建一个 cur 指针变量,使用 cur 指针来遍历单链表,而不使用 phead 直接去遍历,这样做的目的是有的时候我们需要进行多次遍历,如果把 phead 给改变了,我们之后再想找到头节点就没那么容易了。
接下来我们正式进入到单链表的增删查改环节,这部分内容对于初学者而言会有几个常犯的错误,希望大家能够细心一些。
2.5、单链表的尾插
相信有很多同学在刚开始学习单链表尾插的时候,代码是这样写的:
void SLTPushBack(SLTNode* phead, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
SLTNode* ptail = phead;
while(ptail->next)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
int main()
{
SLTNode* plist = BuySLTNode(1);
SLTPushBack(plist, 100);
SLTPrint(plist);
return 0;
}
这样代码写完了之后,很多同学们在验证时并没有遇到什么错误,就以为代码已经完善了,但其实这样是不可以的,试想一下,当我们给一个 空链表 进行尾插操作的时候,ptail = phead 都是空指针,这个时候再进行解引用操作,是不是就报错了呢?那么我们根据这个思路来改善一下:
void SLTPushBack(SLTNode* phead, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
if(phead == NULL)
{
phead = newnode;
}
SLTNode* ptail = phead;
while(ptail->next)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
int main()
{
SLTNode* plist = NULL;
SLTPushBack(plist, 100);
SLTPrint(plist);
return 0;
}
这样改好了之后,这个代码看起来好像已经无懈可击了,但是如果有细心的同学有意识的去用一个空链表代入检查一下的时候,就会发现 “欸? 为什么我插入了一个、两个、三个节点之后,这个链表还是一个空呢?我插入的数据去哪了?”
这是因为这里又涉及到了一个问题,那就是在函数调用的过程中,形参的改变不会影响到实参,我们在调用尾插函数的时候,在建立函数栈帧并传参时,在栈区生成了一个单链表头节点的临时拷贝,我们在尾插函数中把 phead 指向新的节点,只是把这一份临时拷贝的变量给改变了,并没有改变单链表的头节点本身。这部分内容参考我之前写的一篇文章《函数栈帧的创建与销毁》。
那么,到底怎么样才能避免以上的种种问题,实现单链表的尾插操作呢?答案也很简单,要在函数中改变一个变量,只需要获得这个变量的地址就可以了,根据这个思路,我们在传参时就要去传递单链表的二级指针。
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
if(*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* ptail = *pphead;
while(ptail->next)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
}
int main()
{
SLTNode* plist = NULL;
SLTPushBack(&plist, 100);
SLTPrint(plist);
return 0;
}
这样,单链表的尾插操作才算是全部完成了,怎么样,同学们有感觉很绕吗?
2.6、单链表的尾删
学习完了单链表的尾插之后,同学们再回过头来思考一下,到底为什么要使用二级指针?
这是因为在单链表的尾插过程中有可能会出现改变 phead 的情况,那么在单链表的尾删过程中是不是也会出现这种情况呢? 如果单链表本来就只有一个节点,那么再尾删一次,就需要把phead指向空。所以单链表的尾删也需要使用二级指针。
具体代码如下:
void SLTPopBack(SLTNode** pphead)
{
assert(*pphead); //如果单链表本来就是空,就不允许删除,直接断言报错
if((*pphead)->next == NULL)
{
free(*pphead);
*pphead == NULL
}
else
{
SLTNode* ptail = *pphead;
while(ptail->next->next)
{
ptail = ptail->next;
}
free(ptail->next);
ptail->next = NULL;
}
}
当链表中有多个节点时,使用ptail指针向后遍历,当找到倒数第二个结点的时候停止,删除最后一个节点,并把倒数第二个节点(这个时候已经变成最后一个了)的指针成员变量指向空。
当链表中就只剩下一个节点的时候,删除该节点,并把单链表的指针 phead 指向空(需要用二级指针来操作)。
以上就是单链表的尾部操作。
2.7、单链表的头插
经过了前两种操作的学习,相信大家已经理解为什么需要使用二级指针,以及什么时候会用到二级指针了,那么同理,实际上单链表的头插、头删以及中间插入、中间删除也都是需要使用二级指针的,以下就不再过多赘述了。
单链表头插的具体代码如下:
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
代码比较简单,直接创建一个新的节点,把新节点的next指针指向旧的单链表地址,再把旧的单链表地址更新成新的节点的地址
2.8、单链表的头删
与单链表的头插类似,代码也很简单:
void SLTPopFront(SLTNode** pphead)
{
assert(*pphead)
SLTNode* newhead = (*pphead)->next;
free(*pphead);
*pphead = newnode;
}
这里有一个需要注意的地方是,需要先将phead的下一个节点的地址保存起来,再free掉phead,否则就会导致找不到下一个节点的位置,造成内存泄漏。
2.9、单链表寻找节点
为了能够实现单链表的中间插入和删除,我们需要进行一些前置操作,那就是找到单链表中指定的节点位置。
代码如下:
STLNode* SListFind(SLTNode* phead, SLTDataType x)
{
if(phead == NULL)
{
return NULL;
}
SLTNode* cur = phead;
while(cur)
{
if(cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
使用cur指针向后遍历,如果找到与节点的数据变量相同的数据,就返回该节点,如果没找到就返回空。
2.10、单链表的中间插入(形式一)
有了查找函数之后,我们就可以实现在单链表中间的指定节点位置插入与删除节点了,首先我们实现从指定节点的后面插入新节点,先看代码:
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos); //pos不能为空,要不然找不到要在哪里插入,所以直接设置断言报错
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
我们只需要新建一个节点newnode,然后把 newnode 插到指定节点 pos 的后面就可以了,需要注意的是这里需要先把 newnode 的 next 指向 pos 的下一个节点,再把 pos 的 next 指向newnode。
2.11、单链表的中间插入(形式二)
接下来实现在指定节点的前面插入节点,代码如下:
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySLTNode(x);
if(pos == *pphead)
{
newnode->next = *pphead;
*pphead = newnode;
}
else
{
SLTNode* prev = *pphead;
while(prev->next != pos)
{
prev = prev->next;
}
newnode->next = pos;
prev->next = newnode;
}
}
因为单链表是单向的,为了找到pos节点之前的节点,我们需要把 phead 也一起传过去,又因为这次我们是在pos节点的前面插入新的节点的,所以很有可能会出现需要改变 phead 的情况,因此,我们需要使用二级指针。
2.12、单链表的中间删除(形式一)
首先我们来实现删除单链表指定节点的后面的节点,代码如下:
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
if(pos->next == NULL)
{
return;
}
else
{
SLTNode* nextNode = pos->next;
pos->next = nextNode->next;
free(nextNode);
}
}
先让pos节点的next指针指向 netNode 的下一个节点,再把nextNode给free掉。
2.13、单链表的中间删除(形式二)
接下来实现删除pos节点本身,代码如下:
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pos);
if(pos == *pphead)
{
SLTPopFront(pphead);//直接复用头删就可以了
}
else
{
SLTNode* prev = *pphead;
while(prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
因为单链表是单向的,为了找到pos节点之前的节点,我们需要把 phead 也一起传过去,又因为这次我们要删除 pos 节点,而pos节点又很有可能就是 phead ,所以可能会出现需要改变 phead 的情况,因此,我们需要使用二级指针。
经过这部分的学习,我们已经完成了单链表全部的增删查改操作,大家整理一下思绪,我们来进行下一个非常简单的操作放松一下吧。
2.14、单链表的销毁
我们在销毁单链表时,是一定要把 phead 指向空的,即需要改变 phead,所以我们依然使用二级指针操作:
void SLTDestroy(SLTNode** pphead)
{
SLTNode* cur = *pphead;
while(cur)
{
SLTNode* next = cur->next;
free(cur);
cur = cur->next;
}
*pphead = NULL;
}
使用 cur 变量往后遍历,把所有的节点都释放掉,最后别忘了把单链表的地址指针指向空!
到这里,关于单链表的基础操作就已经学习完了,辛苦同学们能够看完,因为本人水平有限,如果有出现纰漏的地方希望大家能够帮我指正,谢谢各位!