003 数据结构_无头单向非循环链表的详细分解——“C”
引入
前言
本文介绍的是无头单向非循环链表,这种链表结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
链表是什么
常见的链表包括:
单向链表(singly linked list):单向链表是最基本的链表形式,每个节点只有一个指针指向它的后继节点。
双向链表(doubly linked list):双向链表在单向链表的基础上增加了一个指针,使得每个节点不仅可以访问它的后继节点,也可以访问它的前驱节点。
循环链表(circular linked list):循环链表与单向或双向链表相似,唯一的区别在于,链表的最后一个节点指向第一个节点,从而形成了一个循环。
无头链表(headless linked list):无头链表是一种特殊的链表结构,它没有头节点,直接将链表的第一个元素作为起始节点。
链表中有环的问题(linked list cycle problem):这不是一种特定的链表类型,而是一种经典的算法问题。给定一个链表,判断其中是否存在环的问题,通常采用快慢指针的技巧来解决。
什么是单链表
单链表(singly linked list)是一种常用的数据结构,它由多个节点(node)构成。每个节点都有一个存储的值和一个指向下一个节点的指针(next)。通常用头结点来表示整个单链表。
什么是节点
节点(node),是指单链表中的每个元素,它包含两个部分:存储数据元素的数据域和一个指向下一个节点的指针(next)。在单链表中,第一个节点称为头节点(head node),它不存储任何数据元素,只有一个指向第一个真正存储数据元素的节点的指针。最后一个节点称为尾节点(tail node),它的指针指向 null,表示链表结束。
我们通常用一个结构体来表示节点
如下:
typedef int SLTDateType; //重命名int类型名字
typedef struct SListNode
{
SLTDateType data; //存储当前节点的数据
struct SListNode* next; //指向下一个下一个节点的指针
}SListNode;
单链表的基本功能
增:
单链表尾插
void SListPushBack(SListNode** pplist, SLTDateType x);
单链表的头插
void SListPushFront(SListNode** pplist, SLTDateType x);
在任意位置后插入
void SListInsertAfter(SListNode* pos, SLTDateType x);
在任意位置前插入
void SListInsert(SListNode** pphead, SListNode* pos, SLTDateType x);
删:
单链表的尾删
void SListPopBack(SListNode** pplist);
单链表头删
void SListPopFront(SListNode** pplist);
在任意位置后删除
// 分析思考为什么不删除pos位置?
void SListEraseAfter(SListNode* pos);
查:
查找
SListNode* SListFind(SListNode* plist, SLTDateType x);
改:
pos = SListFind(&plist, 70); //查找
pos->data = 30; //修改
话不多说——>开肝
步骤分解
扩容
//购买节点
SListNode* BuySListNode(SLTDateType x)
{
SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
if (newnode == NULL)
{
perror("malloc fail");
return NULL;
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
插入
头插
// 单链表头插
void SListPushFront(SListNode** pplist, SLTDateType x)
{
SListNode* newnode = BuySListNode(x);//购买节点
//第一种情况
if(*pplist == NULL) //里面无节点
{
*pplist = newnode; //把节点赋给空节点,*pplist作为第一个节点
}
//第二种情况
else
{
newnode->next = *pplist; //让newnode成为第一个节点,链接下一个节点*pplist
*pplist = newnode;//把newnode后面所有的节点赋给*pplist
}
}
解析图解:分为两种情况。一般我们先想到的是第二种,即一开始单链表内不为空,存在着节点
即第二种情况下
newnode->next = *pplist
*pplist = newnode
解析:注意这句代码的理解,把newnode节点赋给头节点,由于当单链表不为空时,会执行newnode->next = *pplist此语句,让newnode节点链接头节点,因此我们每一次头插完后都应该让头插后的 *pplist成为头节点
让 *pplist表示单链表
执行 *pplist = newnode后
尾插
// 单链表尾插
void SListPushBack(SListNode** pplist, SLTDateType x)
{
SListNode* newnode = BuySListNode(x);
//第一种情况
if (*pplist == NULL)
{
*pplist = newnode;
return;
}
//SListNode* phead = pplist;
//
//改变结构体,使用结构体指针,把&pplist地址传给phead
SListNode* phead = *pplist; //由于用pplist二级指针
//理解:phead指针等于*pplist指针,地址也相同,之前所说的通过修改*phead的值影响不到*pplist是错误的
// 把一个值传给形参,通过修改形参妄想修改到实参的值,但形参一出作用域就销毁了,因此这里说传址
//第二种情况
while (phead->next != NULL)
{
phead = phead->next; //找到phead->NULL
}
phead->next = newnode; //链接节点
}
解析图解:尾插也分为两种情况,第一种为单链表为空时的尾插
第二种为单链表不为空时的尾插,一般下我们先考虑第二种情况
1、找到尾节点
phead = phead->next;
相当于遍历单链表,找到尾节点
2、链接尾节点和newnod节点
phead->next = newnode;
在任意位置的前方插入
//任意pos位置的前插
void SListInsert(SListNode**pphead, SListNode* pos, SLTDateType x)
{
SListNode* newnode = BuySListNode(x); //扩容,购买节点
SListNode* prev=*pphead;
SListNode* cur = NULL;
if (*pphead == pos)
{
SListPushFront(pphead,x);
}
else //problem!!!!
{
while (prev != pos) //遍历找到prev
{
cur = prev; //保存prev移动前:第一个节点的位置
prev = prev->next;
}
newnode->next = pos; //链接newnode和pos节点
cur->next = newnode; //链接prev与newnode节点
}
}
解析图解:分为两种情况,第一种是单链表中只存在一个节点,我们在此节点前插入一个newnode节点,这里复用头插即可。
第二种情况是单链表中存在两个及以上的节点,我们需要找到任意位置处节点前一个节点,和任意位置处的后一个节点
PS:这里经常会出现一个问题
while (prev != pos) //遍历找到prev
{
cur = prev; //保存prev移动前:第一个节点的位置
prev = prev->next;
}
我们看到这个循环语句,这句cur = prev; 是一定不能忘记的
由于prev != pos,prev会一直找到pos节点后停止,如果不记录prev找到pos节点前的位置,那么当prev != pos,会执行prev = prev->next;。当下一个节点就是pos的话,此时pos节点是等于prev节点的
当执行下面语句时
newnode->next = pos; 链接newnode和pos节点
cur->next = newnode; 链接prev与newnode节点
会出现死循环的情况,即自己链接自己
在任意位置的后方插入
//任意pos位置向后插入
void SListInsertAfter(SListNode* pos, SLTDateType x)
{
SListNode* newnode = BuySListNode(x); //扩容,购买节点
newnode->next = pos->next; //链接newnode与pos的下一个节点
pos->next = newnode; //链接pos与newnode节点
//ps:这里不能先链接pos与newnode节点,否则pos->next(pos的下一个节点没有办法表示)直接丢失了链接
}
解析图解:
删除
头删
// 单链表头删
void SListPopFront(SListNode** pplist)
{
assert(pplist); // 确保地址没有传错
assert(*pplist); //节点为空,删什么删
SListNode* del = *pplist; //让del节点记录头节点
*pplist = del->next;
}
解析图解:
尾删
// 单链表的尾删
void SListPopBack(SListNode** pplist)
{
assert(pplist); // 确保地址没有传错
assert(*pplist); //节点不为空,删什么删
SListNode* del = *pplist;
SListNode* cache = NULL;
while (del->next!= NULL) //找尾节点
{
cache = del; //记录尾节点前一个节点
del = del->next;
}
cache->next = NULL; //置空尾节点前一个节点的next
//否则等到打印plist->next,next已经被释放了,访问野指针
free(del); //释放尾节点内存
}
解析图解:
删除任意位置后方的节点
// 单链表删除pos位置之后的值
void SListEraseAfter(SListNode** pphead, SListNode* pos)
{
SListNode* prev = *pphead;
while (prev != pos) //遍历找到prev
{
prev = prev->next;
}
prev->next = pos->next->next;
}
解析图解:
查找
//单链表查找
SListNode* SListFind(SListNode** pplist, SLTDateType x)
{
assert(*pplist); //为空,查什么查
assert(pplist); //检查是否传错
SListNode* phead = *pplist;
if (phead->data == x)
{
return phead;
}
while (phead->data != x)
{
phead = phead->next;
}
return phead;
}
打印
// 单链表打印
void SListPrint(SListNode* plist)
{
assert(plist);
//SListNode* phead = plist;
while (plist->next!= NULL)
{
printf("%d->", plist->data); //当找到最后一个节点后,最后一个节点也要打印
plist = plist->next; //遍历
}
printf("%d->NULL\n", plist->data);
}
销毁
在使用完malloc()分配的内存后,应该释放(deallocating)它以免发生内存泄漏(memory leak)。
内存泄漏指的是程序申请了一段内存,但在不再使用时却没有及时释放这段内存,导致这部分内存永远无法被别的程序或操作系统回收利用。随着内存泄漏不断发生,程序运行时可用的内存会越来越少,直到最终耗尽所有可用内存,导致程序崩溃或系统故障。
为了避免这种情况发生,必须使用free()函数将malloc()分配的内存释放掉。这将把已分配的内存标记为可重用,可以提供给其他部分使用。同时,销毁了malloc()分配的内存后,应该将与之关联的指针设置为空指针,以确保不会在后续的代码中引用未知的内存地址。
void SListDestroy(SListNode** plist)
{
assert(*plist);
SListNode* cur = *plist;
while (cur) //边迭代边释放
{
SListNode* next = cur->next; //保存下一个节点
free(cur);
cur = next;
}
*plist = NULL; //确保不会在后续的代码中引用未知的内存地址
}
通过监视可以观察到cur指向的当前节点已经被释放
总之,当我们使用malloc()函数分配内存时,就要在合适的时机使用free()函数销毁分配的内存,并在此之后及时将与之关联的指针置为空。这样可以避免内存泄漏,提高程序运行效率和可靠性。
测试用例
void test1()
{
SListNode* plist = NULL; //一开始为空
SListPushBack(&plist, 1); //尾插
SListPushBack(&plist, 2); //尾插
SListPushBack(&plist, 3); //尾插
SListPushFront(&plist, 30); //头插
SListPopFront(&plist); //头删
SListPopBack(&plist); //尾删
SListPrint(plist); //打印
SListPopBack(&plist); //尾删
SListPrint(plist); //打印
SListDestroy(&plist); //销毁
printf("\n");
}
void test2()
{
SListNode* plist = NULL; //一开始为空
SListPushFront(&plist, 6); //头插
SListNode* pos = SListFind(&plist, 6); //查找
//任意位置的向前插入
SListInsert(&plist, pos, 10);
SListInsert(&plist, pos, 20);
SListInsert(&plist, pos, 30);
//任意位置的向后插入
SListInsertAfter(pos, 40);
SListInsertAfter(pos, 50);
SListPrint(plist); //打印
SListDestroy(&plist); //销毁
printf("\n");
}
void test3()
{
SListNode* plist = NULL;
//一开始为空
SListPushBack(&plist, 1); //尾插
SListPushBack(&plist, 2); //尾插
SListPushFront(&plist, 3); //头插
SListPushFront(&plist, 4); //头插
SListPrint(plist); //打印
//修改任意pos处节点
SListNode* pos = SListFind(&plist, 3); //查找
pos->data = 30; //修改
SListPrint(plist); //打印
SListDestroy(&plist); //销毁
printf("\n");
}
void test4()
{
SListNode* plist = NULL; //一开始为空
SListPushBack(&plist, 1); //尾插
SListNode* pos = SListFind(&plist, 1); //查找
//任意位置的向前插入
SListInsert(&plist, pos, 10);
SListInsert(&plist, pos, 20);
SListInsert(&plist, pos, 70);
//任意位置的向后插入
SListInsertAfter(pos, 40);
SListInsertAfter(pos, 50);
SListInsertAfter(pos, 60);
SListPrint(plist); //打印
SListEraseAfter(&plist,pos); //删除pos节点位置后的数据
SListEraseAfter(&plist,pos); //删除pos节点位置后的数据
SListEraseAfter(&plist,pos); //删除pos节点位置后的数据
SListPrint(plist); //打印
//修改任意pos处节点
pos = SListFind(&plist, 70); //查找
pos->data = 30; //修改
SListPrint(plist); //打印
SListDestroy(&plist); //销毁
printf("\n");
}
int main()
{
test1();
test2();
test3();
test4();
return 0;
}
总结:
本文介绍了数据结构单链表的实现步骤,这一板块需要画图来理解,多画图,多调试才能学得明白,调试方面的话,可以参考我的001 VS配置c语言环境,以及一些入门技巧——“C”这一篇文章哦
ps:需要源代码的友友们,我已上传到Gitee 手撕无头单向非循环链表自取哦
后续会介绍带头双向循环链表,这种链表结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。