目录
前言:
顺序表的缺陷:
单链表:(Single Linked List)
概念及结构:
单链表的实现:
头文件:SList.h
malloc函数:
free函数:
具体函数的实现:SList.c
单链表的打印:
创建一个新的结点:
单链表尾的插:
单链表的头插:
单链表的尾删:
单链表的头删:
单链表的查找:
在单链表pos位置之前插入数据:
在单链表pos位置之后插入数据:
在单链表pos位置删除数据:
在单链表pos后一个位置删除数据:
单链表的销毁:
总结:
本文用C语言来描述数据结构中的单链表,下文实现的只是简单的无头非循环链表,包括单链表的增加数据,删除数据,查找指定数据,修改指定数据,也就是简单的增删查改等操作。
优点:
缺点:
基于顺序表的缺点,于是就设计出了链表结构。
1.概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表 中的指针链接次序实现的 。
2.单链表的结构为:
3.无头单向非循环链表:
无头单向非循环链表的结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的 子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
备注:
一般我们写一个项目的时候,将所要包含的头文件,函数的声明,结构体等放在一个头文件.h 里面,一般将函数的定义也就是函数实现的过程放在.c的文件里面,一般将函数的测试也就是主函数写在另一个.c的文件里面也就是test.c.这个文件里面。
#include
#include
#include
#include
#include
typedef int SLDataType;
//单链表结构的基本定义
//逻辑结构
typedef struct SListNode
{
SLDataType data;//val - 数据域
struct SListNode* next;//存储下一个结点的地址 - 指针域
}SListNode, SLN;
//打印单链表
void SListPrint(SListNode* phead);
//单链表的尾插
void SListPushBack(SListNode** pphead, SLDataType x);
//单链表的头插
void SListPushFront(SListNode** pphead, SLDataType x);
//单链表的尾删
void SListPopBack(SListNode** pphead);
//单链表的头删
void SListPopFront(SListNode** pphead);
//单链表的查找
SListNode* SListFind(SListNode* phead, SLDataType x);
//在单链表pos位置之前插入数据
void SListInsertBefore(SListNode** pphead, SListNode* pos, SLDataType x);
//在单链表pos位置之后插入数据
void SListInsertAfter(SListNode* pos, SLDataType x);
//在单链表pos位置删除数据
void SListErase(SListNode** pphead, SListNode* pos);
//在单链表pos后一个位置删除数据
void SListEraseAfter(SListNode* pos);
//单链表的销毁
void SListDestroy(SListNode** pphead);
一个struct SListNode类型的结构体又叫做一个结点(节点),包含数据域和指针域,数据域存放的是一个数据,指针域存放的是下一个结点的地址。
C语言提供了一个动态内存开辟的函数,函数原型如下:
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针:
C语言提供了专门用来做动态内存的释放和回收的函数:函数原型如下:
这个函数用来释放动态开辟的内存:
同时malloc和free都声明在 stdlib.h 头文件中,malloc函数是在堆区申请空间的。
//malloc函数的使用:
int main()
{
//开辟10个整形的空间
//int arr[10];
int* p = (int*)malloc(sizeof(int) * 10);
if (p == NULL)
{
printf("%s\n", strerror(errno));
return 0;//结束代码
}
//使用
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
for (i = 0; i < 10; i++)
{
printf("%d ", p[i]);
}
free(p);//当释放后p就变成野指针了
p = NULL;
return 0;
}
malloc函数最好要和free函数配合使用,不然申请空间不释放(虽然在程序结束时申请的内存会被回收)但在程序结束前就可能会造成内存泄漏。free掉空间之后要将空间的首地址置空,在后续操作中该指针可能被用到从而造成非法访问。
单链表需要头指针来存放头结点的首地址,所以在测试.c文件的文件中要创造一个phead是
struct SListNode* 类型的头指针用来存放头结点的地址。
即 struct SListNode* phead = NULL;
当链表为空的时候,头指针就为空指针NULL。
//单链表的打印
void SListPrint(SListNode* phead)
{
//assert(phead); - 不需要断言 - 如果为空指针的话就是空链表
SListNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
phead拿到传过来的链表的头指针,然后由头指针遍历整个链表,将每个结点中的数据打印出来。
//创建一个新的结点
SListNode* BuySListNode(SLDataType x)
{
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
//对malloc函数返回值的判断
if (newnode == NULL)
{
//printf("malloc fail\n");
printf("%s\n", strerror(errno)); //报出错误
exit(-1); //结束程序
}
else
{
newnode->data = x;
newnode->next = NULL;
}
return newnode;
}
通过malloc函数在堆区申请一块新的结点,将数据放在结点中,将指针域置空,并返回这块新结点的首地址。
将新创建的结点接到原来链表上去
//单链表的尾插 - 将新创建的结点接到原来链表上去
void SListPushBack(SListNode** pphead, SLDataType x)
{
assert(pphead); //pphead - 是头指针的地址
//创建一个新的结点
SListNode* newnode = BuySListNode(x);
if (*pphead == NULL)//空链表的尾插
{
*pphead = newnode;
}
else //链接到最后一个结点 遍历(找尾)
{
SListNode* tail = *pphead;
//找尾
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
//将新的头的地址链表最后一个结点里的指针变量。
//新的结点的头指针和原来链表的尾指针都在堆区。
}
}
1.这里用到了二级指针的解释:
正常情况下的尾插:
如果尾插函数第一个参数是SListNode* phead,那么phead就是SListNode*类型的一级指针,接收的是链表的头指针,这时tail应被赋值成:SListNode* tail = phead,(tail此时指向的是链表的头结点)当链表不为空的时候,tail通过while循环找到链表的尾,将新的结点链接到链表的尾部。
特殊情况下的尾插:(注意)
如果尾插函数第一个参数是SListNode* phead,当尾插的链表是空链表的时候,这时如果只是单纯的将phead = newnode;这样操作之后,实际上的头结点并没有被改变,因为函数的形参只是实参的一份临时拷贝,尾插函数只是将形参的phead的值改成了newnode并没有将真正的头指针改变,同时函数结束时这个phead局部变量会被销毁。所以这里要用到二级指针来接收头指针的地址,之前C语言的学习中函数章节中提到,函数的传值和传址的区别,想要改变值就要传地址(指针),同样的道理这里要想改变地址,就得传地址的地址,所以形参用到了二级指针(参考上正确述代码)。
2.测试函数中就该这样写:
struct SListNode* head = NULL;
SListPushBack(&head);
3.注意点:
有些初学者会在找尾的时候出现经典的错误:
同样的道理:tail指针是在函数中创建的,是在栈区的局部变量,tail = newnode;的操作只是将这个局部变量的值改成了newnode,并没有改变链表中结点的指针域,当函数结束时tail的内容也会被销毁,同样新结点并没有链接到链表的尾部。
4.同样值得注意的是:
这里是将空链表的情况单独处理的,因为tail是赋值成头指针的,当链表为空的时候,*pphead == NULL;tail也是NULL。当找尾的时候,循环进入条件tail->next;这个操作就可能涉及到了对空指针的引用。就会出现错误,所以就将空链表的情况单独拿出来处理。
5.小结:
只要涉及改变链表头指针的操作,就要传二级指针。
//单链表的头插
void SListPushFront(SListNode** pphead, SLDataType x)
{
//创建一个新的结点
SListNode* newnode = BuySListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
思路:
将新的结点的尾链接到原来链表的头,再将头指针指向新结点的头。
注意:
这里要注意链接的顺序,如果先将头指针指向新结点的头,再将新的结点的尾链接到原来链表的头的话,原来链表的头就找不到了,因为原来链表的头是放在头指针里面的,若先将头指针改了就找不到原来的头了,就链接不上了。
void SListPopBack(SListNode** pphead)
{
assert(pphead);
//1.空链表
//2.一个结点
//3.多个结点
//空链表的情况
//暴力检查 - assert(*pphead != NULL);
if (*pphead == NULL)//温柔检查
{
return;
}
//只有一个结点的情况
else if ((*pphead)->next == NULL)//解引用和箭头的优先级一样高这里要带括号
{
free(*pphead);
*pphead = NULL;
}
//多个结点的情况
else
{
SListNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
因为这里涉及改变链表的头指针所以传的是二级指针。
思路:
在链表的尾部删除结点通常的想法就是把要删除的结点free掉,然后将要删除的结点的前一个结点的指针域置成空,但是如果遍历链表找到尾结点的话,就不能再找到尾的前一个结点。这时当务之急就是找到要删除的结点的前一个结点,这时就会想到循环判断条件为tail->next->next;这样向后找到尾的前一个结点地址,并且通过这个结点还能找到要删除的结点。
但这样的找法会在极端情况下出错,例如空链表和只有一个结点的情况,所以便有了以下的分析。
这里分为三种情况考虑:
1.链表为空链表:
当链表为空链表时,就不存在删除数据的情况,因为没有是数据可删,直接结束函数即可。
2.链表只有一个结点:
当链表只有一个结点时,tail->next->next;会出现对空指针NULL引用,所以要单独拿出来处理。
3.链表有多个结点 :
就可以按照通常思路找尾,先将尾结点释放掉,再将指向尾结点的指针即倒数第二个结点的指针域置空(NULL)。
这里要注意置空和释放的顺序,如果先置空的话,就找不到尾结点也就不能free释放掉要删除的结点,这样会造成内存泄露。
//单链表的头删
void SListPopFront(SListNode** pphead)
{
assert(pphead);
//1.空
//2.非空
if (*pphead == NULL)
{
return;
}
else
{
SListNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
}
因为这里涉及改变链表的头指针所以传的是二级指针。
思路:
这里要考虑两种情况,链表为空的时,和链表为非空的时。
1.当链表为空的时:
直接结束函数,因为没有可删除的结点。
2.当链表不为空时:
将链表的头指针释放,再将头指针指向第二个结点。
//单链表的查找
SListNode* SListFind(SListNode* phead, SLDataType x)
{
SListNode* cur = phead;
while (cur != NULL)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
思路:
定义一个cur指针从头到尾依次整个链表,只要找到符合条件的结点,就返回该结点的地址。
//在单链表pos位置之前插入数据
void SListInsertBefore(SListNode** pphead, SListNode* pos, SLDataType x)
{
assert(pphead);
assert(pos);//空链表排除
//1.pos是第一个结点
//2.pos不是第一个结点
if (pos == *pphead)
{
SListPushFront(pphead, x);
}
else
{
SListNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
SListNode* newnode = BuySListNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
这个函数需要配合SListFind函数使用来找到pos位置。
assert断言,将pos和pphead为空指针的情况排除。
因为这里涉及改变链表的头指针所以传的是二级指针。
思路:
分两种情况:当pos前为空的时,和pos前不为空时。
1.当pos前为空时:
就相当于头插,直接调用头插函数。
2.当pos前不为空时:
创建一个指针遍历链表找到pos位置前一个结点,再通过创建结点函数创建一个新的结点再将其链接到单链表中。
//在单链表pos位置之后插入数据
方法一:(无关顺序)
//void SListInsertAfter(SListNode* pos, SLDataType x)
//{
// assert(pos);
// SListNode* next = pos->next;
// SListNode* newnode = BuySListNode(x);
// pos->next = newnode;
// newnode->next = next;
//
//}
//方法二:(注意顺序)
void SListInsertAfter(SListNode* pos, SLDataType x)
{
assert(pos);
SListNode* newnode = BuySListNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
这个函数需要配合SListFind函数使用来找到pos位置。
因为这里涉及改变链表的头指针所以传的是二级指针。
思路:
这时已经拿到了pos位置只需要将申请的新结点链接在指定位置便可,同样要注意链接的顺序问题,如果操作不当会造成原链表pos位置后的结点找不到的问题。
这里提供了两种方法:
第一种:创建临时变量来存放原链表pos位置下一个结点地址,这样就不会丢失了。
第二种:就是直接链接但是要注意链接顺序。
//在单链表pos位置删除数据
void SListErase(SListNode** pphead, SListNode* pos)
{
assert(pphead);
assert(pos);
//当传头指针时
if (*pphead == pos)
{
SListPopFront(pphead);
}
else
{
SListNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
这个函数需要配合SListFind函数使用来找到pos位置。
思路:
分为两种情况:一种是只有一个结点和多个结点的情况。
1当链表只有一个结点时:
也就相当于头删,直接调用头删函数。
2.当链表有多个结点时:
这里创建了一个prev指针来遍历链表,找到pos位置之前的结点,将pos的下一个位置链接到prev的尾部,再将pos位置free释放掉。
注意:
这里还是要注意释放和链接的顺序的问题,如果先free(pos)释放pos位置的话,pos->next就找 不到了。
pos = NULL;这一步置空是个好习惯。
//在单链表pos后一个位置删除数据(不可能是头删)
void SListEraseAfter(SListNode* pos)
{
assert(pos);
SListNode* next = pos->next;
if (next != NULL)
{
pos->next = pos->next->next;
free(next);
next = NULL;
}
}
这个函数需要配合SListFind函数使用来找到pos位置。
assert断言,将pos为空指针的情况排除。
思路:
分三种情况 :一种是pos下一个有结点,一种pos是头,一种是pos下一个是空。
1.pos下一个有结点:
直接创建一个指针next,用来存放pos->next,设置这个next临时变量的作用是防止free释放要删除结点的时候找不到要删除的结点,因为要将pos下下一个结点接到pos->next的位置,但是要删除的结点头是pos->next,如果先链接的话,pos->next就会被改了,被删除的结点就找不到了,就不能free释放掉该结点,有可能会造成内存泄露。
2.pos是头:
这里不可能是头删,因为这里 pos最靠进表头只能传头指针,头结点后一个也不可能是头删。
3.pos下一个是空:
那就没的删只能结束函数。
//单链表的销毁
void SListDestroy(SListNode** pphead)
{
assert(pphead);
//一个一个结点释放
SListNode* cur = *pphead;
while (cur)
{
SListNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
思路:
用一个指针(cur)遍历整个链表,同时还需要一个指针(next)用来存放当前指针的下一个结点地址,循环free释放当前cur指向的结点。再继续迭代,cur再指向next的位置,再次进循环,直到链表遍历结束,这样就将整个链表申请的节点空间全部释放了。
数据结构这方面,考虑问题一定要全面,不能将通常情况当做所有的情况,要考虑到极端的个别情况,并将各个细小的细节处理妥当,当程序出现问题时,应当多思考,多调试,用多组数据进行测试,发散思维,有利于能力的提升!