单链表增删查改
前言
链表是数据结构中一种特别重要的结构,它和顺序表同属于线性表的一种。不同于顺序表的是,链表并没有局限于物理内存上的连续性,而是一种在逻辑上连续的线性表,因此链表可以实现很多顺序表难以实现的功能。
形象的理解,链表结构就像现实中链条一样,将一串单个物体串联在一起。
数据结构中的链表是主要这样:
链表分为多种类型,有单向链表、双向链表、带头链表、不带头链表等等……
虽然链表类型多种多样,但实际中最常用的只有以下两种:
本篇文章我将主要向大家介绍无头单项非循环链表(单链表),这种链表结构简单,一般不会单独用来存数据。实际中更多的是作为其他数据结构的子结构,如哈希桶、图的领接表等等。所以说学好单链表就可以为后面更学习更复杂的数据结构打下良好的基础。
另外由于单链表结构简单容易出错,因此这种结构在笔试面试中会出现很多。我们想在以后面对这类题的时候游刃有余,就一定要学好单链表底层功能的实现。
接下来我会向大家介绍单链表的几种基本功能,分析每个功能的实现代码并且加以验证。下面开始介绍。
单链表功能实现
首先创建一个工程,建三个文件(如果有人不熟悉这种写法只需关注后面的功能即可):
- LinkList.h头文件存放单链表的声明,以及所有功能接口函数的声明。
- LinkList.c文件存放我们所要实现的功能接口函数。
- test.c为主函数部分,用来测试每个功能是否实现成功。
1.单链表的存储
单链表的结构上面简单提到过了,是这样的:
单链表由多个结点组成,这些结点在物理上并不一定是连续的,但是他们可以通过指针像一条链子一样链接在一起,所以说链表在逻辑上是连续的。
下面我们来定义一个单链表的结点:
typedef int SListDataType;
// 结点
typedef struct SListNode
{
SListDataType data;
struct SListNode* next;
}SListNode;
一个结点中有两个成员,一个是当前结点所存放的内容,还有一个是当前结点指向下一个结点的指针。
2.单链表的打印
单链表的功能实现起来比较复杂,一开始我们先来实现一个比较简单的功能,单链表的打印。
简单想一下要打印单链表就是将链表的所有结点遍历一遍,然后打印每个结点数据域的值。
首先我们应该先拿到链表头结点的地址,然后通过头结点的地址访问数据域的内容,进行打印。再通过指针域拿到下一个结点的地址,访问下一个结点。
实现代码如下:
void SListPrint(SListNode* phead)
{
SListNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
拿到头结点之后我们在函数内部创建一个指针变量cur来存放当前结点的地址,然后通过指针cur访问当前结点的数据。访问完之后,让cur指向下一个节点的地址,持续遍历。当cur的值为NULL是,说明已经打印完最后一个结点了,停止打印。
看懂单链表的打印操作之后,大家也算是简单的熟悉了单链表是如何来操作的。由于此时链表中还未插入数据,所以链表内容为空,打印函数不方便演示,下面我们来实现往单链表中插入元素的功能。
3.单链表申请新结点
在插入元素之前,我们应该先申请一个新结点将要插入的元素放进该结点的数据域,然后再将这个新结点根据要插入的位置来和其它结点连接起来,下面我们就来实现单链表的申请新结点的操作。
函数内部定义一个结构体指针存放新结点的地址,新结点的内存通过malloc函数来动态开辟。开辟成功之后在新结点的数据域放入我们要插入的元素,指针域先置为空指针。需要注意的是这个函数必须要有返回值,返回值正是新结点的地址。
实现代码如下:
SListNode* BuySListNode(SListDataType x)
{
SListNode* newNode = (SListNode*)malloc(sizeof(SListNode));
if (newNode == NULL)
{
printf("申请结点失败\n");
exit(-1);
}
newNode->data = x;
newNode->next = NULL;
return newNode;
}
4.单链表的尾插
学会创建新结点之后,下面我们来实现单链表的尾插操作。
实现尾插操作我们首先要创建一个新结点,这个步骤可以调用前面开辟新结点的函数来完成。新结点创建好之后,下面我们要做的就是将新结点和链表原来尾结点连接起来,就像连接一个表的链子一样,将它连接在后面。
因此我们需要找到单链表的最后一个结点,最后一个结点的特点是它的指针域内容为空指针
。通过这个特点,我们就可以像打印单链表一样遍历一遍单链表的所有结点,当发现结点指针域的内容为空时,说明该结点为最后一个结点,然后将新结点的地址放在原尾结点的指针域中去,再将新结点的指针域置为空指针。
但是这里需要注意尾插操作还要分为两种情况:
第一种:当前链表中原本就存在其他结点
,这样我们就遵循上面的提到的操作即可。
第二种: 该链表中一个结点都没有
,链表的头指针还为空。这个时候想一下,链表中没有内容,那么我们创建的新结点就是该链表的头结点,所以此时不用再找尾结点了,只需将头指针置为新开辟结点的地址。
尾插的基本思想就是这样的了,下面我们就来看看实现代码:
void SListPushBack(SListNode** pphead, SListDataType x)
{
SListNode* newNode = BuySListNode(x);
if (*pphead == NULL)
{
*pphead = newNode;
}
else
{
// 找尾
SListNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newNode;
}
}
代码的实现基本上是按照前面我提到的思路来实现的,先创建一个新结点,然后判断当前链表中有没有元素,如果没有新结点就是头结点,如果有元素,就找到尾结点,将新节点和尾结点连接起来。
实现这个函数我们要接收链表的头结点地址和要插入的元素作为参数,不知道大家有没有注意到,这里头结点的地址是使用一个二级指针来接收的。既然要接收头结点的地址,用一个指针变量来接收不就好了吗,为什么这里要用二级指针来接收。
这里我必须要提一点知识,函数在传参的时候,如果传递的是变量,那么形参和实参就是两块完全不相干的空间,形参仅仅是实参的一份临时拷贝,两者之间不会建立联系
。
根据我前面分析的尾插操作的第二种情况,如果链表中没有结点,我们就要将传进去的头结点改为新结点的地址。而如果传递一级指针,在函数内部改变头结点的值,真正头结点的值并不会发生任何变化。
所以我们在传参的时候一定要将头结点指针的地址传过去,用一个二级指针来接收,这样才能将函数和链表真真的联系起来。
这一块想要理解透彻的话,就一定要对函数的传参和指针的理解有一定深度,如果大家有困难的话,建议好好复习一下这两块的知识。
下面我们就来调用测试文件中的Test函数来验证一下刚刚所写的尾插函数:
#include "LinkList.h"
void Test()
{
SListNode* pList = NULL;
SListPushBack(&pList, 1);
SListPushBack(&pList, 2);
SListPushBack(&pList, 3);
SListPrint(pList);
SListPushBack(&pList, 0);
SListPrint(pList);
}
int main()
{
Test();
}
先连续插入三个元素1、2、3打印一下,然后再插入一个元素0观察尾插的效果,
运行结果:
可以看到第4个元素0尾插进去之后插入到了原尾结点3的后面。
5.单链表的尾删
学会尾插之后我们再来实现单链表的尾删操作。
尾删操作同样要思考多种情况。
第一种:单链表中都一个以上的结点
这个时候我们再进行尾删的时候,只需通过遍历找到尾结点的地址,然后用free函数将它释放掉,再将指针置为空指针。这样一来尾结点我们就算处理完了,但是大家思考一下,尾删的操作到这里就结束了吗?当然不是,脑子里思考一下尾删的步骤,原尾结点处理之后,原尾结点的上一个结点就成了新的尾结点,我们是不是应该将新尾结点的指针域置为空指针啊。
所以这里仅仅创建一个指针变量来找尾结点是不够的,还需要创建一个指针变量来找尾结点的上一个结点,当将尾结点处理完之后再将上一个结点处理为新的尾结点。
第二种情况:链表中只有一个节点
当链表中只有一个结点的时候,说明链表中只有一个头指针指向的结点,这样也就不需要找到尾结点的上一个结点了,直接将头指针给释放掉,再置为空指针即可。
第三种情况:链表为空
当链表为空时,也就是说链表中没有结点,这个时候的删除操作也没有必要进行,直接结束掉函数即可。
实现代码如下:
void SListPopBack(SListNode** pphead)
{
// 1、空
// 2、一个结点
// 3、一个以上结点
if (*pphead == NULL)
{
return;
}
else if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SListNode* prev = NULL;
SListNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;
}
}
仔细分析代码和我上面介绍的思路是一样的,下面我们就来调用Test函数来验证一下尾删操作:
#include "LinkList.h"
void Test()
{
SListNode* pList = NULL;
SListPushBack(&pList, 1);
SListPushBack(&pList, 2);
SListPushBack(&pList, 3);
SListPushBack(&pList, 4);
SListPushBack(&pList, 5);
SListPrint(pList);
SListPopBack(&pList);
SListPrint(pList);
SListPopBack(&pList);
SListPrint(pList);
}
int main()
{
Test();
}
先插入5个元素打印一下,然后调用两次尾删函数并且打印链表内容来看一看效果,
运行结果:
可以看到尾删操作成功删掉了链表最后一个结点的内容。
6.单链表的头插
头插操作比起前面的尾删和尾插就容易的太多了。
头插之前肯定要先申请一个新结点,然后让新结点的指针域指向原来的头结点,再让头结点变成新结点。
实现代码如下:
void SListPushFront(SListNode** pphead, SListDataType x)
{
SListNode* newnode = BuySListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
可以看到真的很简单,下面我们来调用Test函数来验证头插操作:
#include "LinkList.h"
void Test()
{
SListNode* pList = NULL;
SListPushBack(&pList, 1);
SListPushBack(&pList, 2);
SListPushBack(&pList, 3);
SListPrint(pList);
SListPushFront(&pList, -1);
SListPrint(pList);
SListPushFront(&pList, -2);
SListPrint(pList);
}
int main()
{
Test();
}
先尾插3个元素打印,再头插两个元素分别打印,
运行结果:
7.单链表的头删
头删操作其实也不难,就是将原来的头结点给释放掉,再让第二个结点为新的头结点。当然,删除操作链表里的内容不能为空,如果为空直接返回,也就不用再进行删除操作了。
实现代码如下:
void SListPopFront(SListNode** pphead)
{
// 1.空
// 2.一个节点 + 3.一个以上的节点
if (*pphead == NULL)
{
return;
}
else
{
SListNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
}
这段代码也比较简单,但是这里我需要提一点。有的人上来之后直接将原头结点的地址给释放掉,然后再让第二个结点当头结点。但是你试想一下,如果你一上来就把头结点释放掉还给操作系统,那么你如何找到头结点的下一个节点。
所以一开始我们应该先创建一个临时的指针将头结点的下一个节点存放起来,然后再将原来的头结点释放掉,再让第二个结点成为新的结点。
下面调用Test函数来验证一下头删操作:
#include "LinkList.h"
void Test()
{
SListNode* pList = NULL;
SListPushBack(&pList, 1);
SListPushBack(&pList, 2);
SListPushBack(&pList, 3);
SListPushBack(&pList, 4);
SListPushBack(&pList, 5);
SListPrint(pList);
SListPopFront(&pList);
SListPrint(pList);
SListPopFront(&pList);
SListPrint(pList);
}
int main()
{
Test();
}
先插入5个元素,然后进行两次头删操作打印看一下效果,
运行结果:
可以看到每次都删掉原链表的头结点。
8.单链表的查找
单链表的查找就需要遍历一遍链表的每一个结点,当找到某个结点的数据域和要查找的值相等,就将该结点的地址返回出来即可。如果没有找到,返回空指针。
实现代码如下
// 单链表查找
SListNode* SListFind(SListNode* phead, SListDataType x)
{
SListNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
实现代码和我上面分析的基本一致,下面我们就调用Test函数来验证一下查找操作,代码如下:
#include "LinkList.h"
void Test()
{
SListNode* pList = NULL;
SListPushBack(&pList, 1);
SListPushBack(&pList, 2);
SListPushBack(&pList, 3);
SListPushBack(&pList, 4);
SListPushBack(&pList, 5);
SListPrint(pList);
SListFind(pList, 3)->data = 0;
SListPrint(pList);
}
int main()
{
Test();
}
先插入5个元素,然后找到数据域为3的结点,将该结点的数据域内容改为0,打印出来,
运行结果:
可以看到成功找到3所在结点,并将其数据域的内容改为0了。
9.单链表在pos后的位置插入
在pos后的位置插入,首先我们还是要创建个新的结点,然后将这个新的结点连接在pos位置和pos下一个位置之间。让pos结点的指针域指向新结点,再让新结点的指针域指向pos的下一个结点。
实现代码如下:
void SListInsertAfter(SListNode* pos, SListDataType x)
{
assert(pos);
SListNode* newnode = BuySListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
这里可能有人有疑问,为什么不将新节点插入到pos前面的位置呢?那是单链表因为不能通过pos结点找到pos的前一个结点
。当然通过头指针可以找到,但是这就要将整个链表遍历一遍,太麻烦没必要。不过这里可以告诉大家,如果是双向链表的话,就可以进行这个操作了,单链表的确是不太适合。
下面继续调用Test函数来验证:
#include "LinkList.h"
void Test()
{
SListNode* pList = NULL;
SListPushBack(&pList, 1);
SListPushBack(&pList, 2);
SListPushBack(&pList, 3);
SListPushBack(&pList, 4);
SListPushBack(&pList, 5);
SListPrint(pList);
SListNode* pos = SListFind(pList, 3);
SListInsertAfter(pos, 0);
SListPrint(pList);
}
int main()
{
Test();
}
先插入5个元素,然后找到3所在位置的结点,将一个数据域为0的新结点插入到3所在结点的后面,
运行结果:
可以看到成功将0插入到3的后面去了。
10.单链表在pos后的位置删除
同理删除的时候只能删除pos后面的结点。
删除的时候要通过pos指针找到pos的下一个结点,还要找到下一个结点的下一个结点。然后将下一个结点释放掉之后,让pos的指针域指向下下一个结点的地址.
实现代码如下:
void SListEraseAfter(SListNode* pos)
{
assert(pos);
if (pos->next)
{
SListNode* next = pos->next;
SListNode* nextnext = next->next;
pos->next = nextnext;
free(next);
}
}
这里同样要注意先后顺序,先拿到需要的地址,然后再进行释放。
调用Test函数来验证:
#include "LinkList.h"
void Test()
{
SListNode* pList = NULL;
SListPushBack(&pList, 1);
SListPushBack(&pList, 2);
SListPushBack(&pList, 3);
SListPushBack(&pList, 4);
SListPushBack(&pList, 5);
SListPrint(pList);
SListNode* pos = SListFind(pList, 3);
SListEraseAfter(pos);
SListPrint(pList);
}
int main()
{
Test();
}
还是插入5个元素,然后删除3所在结点的下一个结点,
运行结果:
可以看到成功将3后面的结点4删掉了,将3和5连接在了一起。
单链表的所有基本功能到这里就介绍完了,这篇文章的知识还是有一定的深度的,大家在以后做题的时候一定会发现,单链表的题变来变去都离不开这几种功能。如果你能够充分吸收的话,一定会让你对数据结构的理解有一个很大的提升。
最后希望这篇文章能够对大家带来帮助。