数据结构 | 单链表SingleList【带你从浅入深真正搞懂链表】—— 下篇
【摘要】 万字详解单链表,带你从浅入深真正搞懂链表,不再为指针而苦恼
5、查找
- 看完了
尾插、尾删、头插、头删
。接下来我们要学习的是去链表中查找指定元素,先来看看接口定义
SLTNode* SListFind(SLTNode* phead, SLTDataType x);
- 可以看到,形参接收的是头结点指针,以及需要查找的值,返回值类型是一个结构体指针,也就是说返回的是一个==指向待查结点的==结构体指针,并不是一个下标,一个位置,那函数内部我们该如何去写呢?
- 很简单,只需要一个cur指针先获取头结点的指向,然后一个个向后查询即可,若是发现其指向的data值相同,便返回当前的cur指针
SLTNode* cur = phead;
//while (cur != NULL)
while (cur)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
//返回的是指向这个待查结点的指针,并不是位置
- 我们来测试一下看看
SLTNode* SList = NULL;
SListPushBack(&SList, 1);
SListPushBack(&SList, 2);
SListPushBack(&SList, 3);
SListPushBack(&SList, 4);
SListPushBack(&SList, 5);
PrintList(SList);
SLTNode* pos = SListFind(SList, 3);
if (pos != NULL) {
printf("找到了\n");
printf("pos = %d\n", pos->data);
}
else {
printf("没找到\n");
}
- 可以看到,确实是可以找到,我将这个结点的data值打印了出来
那有同学问,找到返回了这个值又能怎样呢,可以拿来做什么?那我告诉你这个用处可大了,后面我们将在找到的这个pos所指的结点之前、之后进行插入和删除相应的结点,都是要以这个Find接口作为前提
6、在pos位置之后插入结点
- 接下来我们就来看一看如何在找到的pos位置之后插入一个结点呢
- 可以看到,我们最后要达到的是这种效果,就是pos的next要指向这个newnode,然后newnode的next要指向pos->next,这时候有些同学就会这么去写。先让这个pos的next指向newnode,然后再让newnode的next指向pos的next
- 这个逻辑其实就有问题,当
pos的next所指位置变化之后也就找不到4这个结点了
,执行完第一条语句后pos的next就变成了newnode,这个时候再让newnode指向pos的next,也就是让newnode指向它自己,这也就不对了
//pos的下一个位置就没了,相当于是newnode自己指向自己
pos->next = newnode;
newnode->next = pos->next;
- 所以应该把这两条语句做一个交换。先让newode的next指向pos的next,也就是4,然后再让pos指向这个newnode,这个时候结点之间的链接就没问题了:hushed:
newnode->next = pos->next;
pos->next = newnode;
- 通过这段代码,我们插入一个值试试看。从打印结果看来,确实在3后面插入了一个9
警惕传入空指针【✒细致讲解断言assert】
- 还是一样,继续思考有没有可能出现特殊情况,我们来看看这种
- 可以看到,链表中的结点值只有1~5但是却需要找一个88,很明显是找不到的,此时便会return NULL,那么外界的pos接受到的值就是一个空值,也就说这是一个空指针,所以当你传一个空指针进去,你让函数内部怎么实现一个插入呢
SLTNode* pos = SListFind(SList, 88);
SListInsertAfter(pos, 9);
PrintList(SList);
- 此时可以看到,程序发生了奔溃/(ㄒoㄒ)/~~
- 看到这个地方,我相信你的第一反应就是在函数一开始的加一个断言,判断这个pos是否为空
- 可能有些同学不了解里面的==传参机制以及判断机制==,我这里讲一下
- [x] 对于assert断言来说,就是还函数还没有执行的时候去做的一件事,所以要
放到一段程序的开头
,而不是应该把它放在函数体的中间或者结尾,这点首先要明确; - [x] 其次assert断言的内部参数你需要传入的是你需要检查的
传入进来的某个参数变量
,一般是去判断是否为空或者是否小于0。所以对于参数,你应该写入的是会报出错误的对立面,举个例子,当你想要检查a是否大于0,a小于0就会报错,因此应该写assert(a > 0)。若是去判断一个指针是否为空,就要这样写:assert(p != NULL),或者直接简写成assert( p ),就像我们的while循环去判空是一个道理; - [x] 知道了传参逻辑,最后说说
assert的判断机制
,就是当你所写的表达式不成立时,也就是当【p == NULL】时,就会出现警告,报出错误。在计算机中【0是假,非0才是真】,当【p = NULL】时,这个表达式就变为了假,非0一般指1,就是p != NULL,表明传进来的指针p不为空,那也就是不会满足条件 - 我们通过CPlusPlus来看看,明确说到当表达式的值为0的时候,就会向标准的错误设备写入一条消息终止调用,也就是断言assert后面的语句均不会执行
- 所以应该在InsertAfter函数的开头加上这一句话
//0是假,非0才是真
assert(pos != NULL); //若不为空,则不会执行;若为空,则报出警告
//assert(pos);
- 此时可以看到,加上这句话后断言就出现了,直接给你报出了
哪个源文件的哪一行出了问题
,也就是我们刚才写断言的地方,这个时候你立马可以进行【精确定位】然后排错,这个时候也就提高了程序的可维护性,所以作为我们程序员来说,学会使用断言是很重要的,但是断言并不是什么地方都可以随便加的,根据需求来加,也就是【因地制宜】,不然会出现麻烦,结尾总结的时候会说到:book:
【生活小案例3——请人吃饭要带钱💴】
- 通过传入空指针去插入一个结点可以看到是一个很荒谬的事情,我们联想一个生活中的小案例来理解一下,顺便轻松一刻:guitar:
- 你传入一个空指针和一个待插入的结点值,其实就相当于你带你说今天请你朋友去外面吃饭,但是吃饭之后一看
钱包是空的
,于是这个时候就只能让你朋友付钱了【假设不支持手机支付】,那你说这个事情是不是很荒谬,assert断言其实就是在出门之前检查一下你有没有带钱
,如果没有带那就会报出警告提醒你要带钱出门 - 所以在函数进行传递指针的时候一定要检查传递的是不是空指针,就想出门检查自己有没有带钱是一个道理
7、在pos位置之前插入结点
- 说完了在这个pos位置之后插入结点,那有后一定有前,现在我们来谈一谈如何在获取到的pos位置前插入一个结点。对于这个,说在前面,会出现两种情况:
- 一种是当前找到的pos在链表中间的某个地方,
- 还有一种比较特殊,就是这个pos刚好是头结点,这个时候进行的其实就是我们上面说过的
头插法
,那既然是头插法,就需要去改变这个链表的头,所以又需要使用到二级指针
void SListInsertBefore(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
if (*pphead == pos) {
//若pos位置就为原先的头结点,则使用头插法进行插入
SListPushFront(pphead,x);
}
else {
SLTNode* cur = *pphead;
while (cur->next != pos)
{
cur = cur->next;
}
SLTNode* newnode = BuySLTNode(x);
//此处无需考虑前后错乱问题,直接链接即可
cur->next = newnode;
newnode->next = pos;
}
}
- 直接给出代码做讲解。一样的老套路,通过*pphead进行解引用,
获取到外界SList这个头结点指针
,然后去进行一个判断即可,若这个 *phead和pos相同,那表明所Find找到的pos就为链表的头结点。直接调用一下我们上面写过的头插法即可,实现了功能的复用,具体图示如下:point_down:
- 然后再来说一下普通情况,也就是在链表其中的某一个位置前插入,所以我们
要获取pos指针所指向的前一个位置
,开头说到过,虽然单链表对于插入和删除很方便,但是访问结点并不方便,需要从头结点开始访问,于是还是一样的套路,定义一个cur指针首先获取到头结点指针的值,也就是把二级指针pphead解引用一下,然后一直去遍历即可,直到这个【cur->next = pos
】为止停下来,表示当前cur所指向的结点所在的下一个位置便是pos,此时就可以做一个链接了,具体图示如下:point_down: - 可以看到,此时无需像在后面插入结点一般去考虑这个先后的顺序关系,只需要将【cur】【newnode】【pos】这三个指针做一个链接即可
- 最后我们来通过测试验证一下结果
- 这是普通情况
- 来看特殊情况
看完了如何插入结点,接下去我们来说说如何在查找到的pos位置前后删除结点
8、删除pos位置之后的结点
- 首先来说一说如何删除pos位置之后的结点
- 首先来看一下函数体声明,很明显,只需要传入一个pos指针即可,其余不需要
void SListEraseAfter(SLTNode* pos)
- 然后我们来考虑函数体内部,首先要做的事情相信你已经很敏感了,对于删除我们都要assert一下这个传入的指针是否为空,若为空则不能对其进行操作。然后需要考虑的就是特殊情况,有什么特殊情况呢?就是下面这种pos为最后一个尾结点时,后面没有结点可删,此时直直接return返回即可,当然你也可以用assert断言
- 然后就是这种正常的情况,我们需要修改pos所在结点的指针域,因为删除了后一个结点,所以要将其指针域改为下一个结点的再下一个结点,于是有同学就会这么写。这里写其实是有问题的,从内存的角度来说,因为pos的next已经改变了,此时【
pos->next
】这块地址已经访问不到了,再去free的话也是徒劳
pos->next = pos->next->next;
free(pos->next);
- 因为我们需要将【pos->next】做一个临时保存,这样当pos的next指针域改变时,也可以访问到这个被删除的结点
- 具体代码如下
void SListEraseAfter(SLTNode* pos)
{
assert(pos);
SLTNode* nextNode = pos->next;
if (nextNode == NULL){
return; //考虑到所查找到的结点为最后一个结点,后面无结点可删
}
else {
pos->next = nextNode->next;
free(nextNode);
}
}
- 那这个时候有同学又来抬杠说,再定义一个看着冗杂,为啥不先把这个结点释放,然后再修改指针域呢?
- 大家觉得这样可以吗❓
free(pos->next);
pos->next = pos->next->next;
- 其实这是一种非常典型的错误,很多同学都容易犯,当你将这个结点free掉之后,那也就是说其data域和next都没了,但是
待删结点3的next域存放有结点4的地址
,你讲它提前释放了,那pos怎么找得到呢❓此时【pos->next】就变成了一种我们都听说过的东西,叫做【==野指针==】,可能有小伙伴没听说过,也没关系,我们可以来了解一下👇
野指针的危害【生活小案例4——酒店房门的钥匙🔑】
- 就我们理解而言,指针都是指向一个地址,野指针也不例外,它指向一个地址,但是呢,这块地址
不是确定的,而是随机的
。 - 这一块我不是非常了解,大家可以看看这篇博客——》野指针的产生及其危害
通过阅读这篇文章,我也了解到了一些有关野指针的知识,但是这么理解起来比较晦涩难懂,因为还是按照我们的惯例,通过生活的一个小案例来帮助大家理解
- 首先来说一说释放结点的含义:对于结点的释放呢,并不是把此结点存放的地址给销毁了,而是此结点所存放的地址的使用权不属于你了,还给了操作系统。对于申请内存就像去酒店开房间,这个房间的使用权就属于你了,不会有人突然闯进来,那释放了就相当于是退房了,这个房间的使用权就不属于你了,但是这个房间还在【这一点很重要,这个房间还在!!】
- 刚才说到酒店开房,我们具体来说说:你在酒店:hotel:开了一个房间,住了一晚后把这个房间退了,但是在走之前打电话找了一个锁匠根据这个房间的所配了一把钥匙。野指针访问随机内存就好比你后一个晚上
没有登记入住但是却通过这把自己装配的钥匙进入了这个房间
,又在里面住了一个晚上然后这个晚上刚好没什么客人,保洁阿姨也请假了,所以你又白嫖了一个晚上😊。于是你打算明天再来住一次,可是这一次,却有很大的祸患,晚上来了一个旅行团,于是很多房间都要重新打扫,然后就发现你还住在这里,就报警把你抓了起来:policeman: - 这把钥匙其实就相当于是野指针。野指针真正的危害在于【==这块内存已经释放了,是一个随机值,但还有一个指针指向这块地址,但是这个地址是不被保护的,随时都有可能出问题==】
- 所以对于野指针的访问不一定会报错,取决你有没有被编译器查到:computer:
通过这么一个案例,大家对野指针这一块应该有所了解了,所以不要轻易将指针乱指,可能那块地址就是随机的
- 看了野指针的危害,我们继续回到代码中来DeBug看看,使用野指针会出现什么情况
free(pos->next);
pos->next = pos->next->next;
- 从图中可以看出,一开始进去的时候,pos->next也就是我们定义的nextNode,存有4的地址,但是当我们先去free之后,
这个地址就不见了,而且数据值也变成了一个随机值
,所以这个时候再去访问pos->next就是一个【野指针】了
- 但是这个时候编译器却不会报错,正常运行下去了,此时我们来看看外界的SList
- 可以看出,确实出问题了,我们需要删除的是pos所指向的next,也就是3这个位置,但是当先free之后,此时4也没有了,找不到了,2的next指向的是一个随机的地址,那编译器对于这种问题不会报错吗?当然会,但不会是在这里,==而是在Print中==
- 因为打印的时候需要随着next指针所存放的地址一一访问,因此当访问到2的next时,便出现了访问异常,因为它访问的是一块不确实的地址,是没有被分配的,里面没有任何东西,因此这时编译器就会报出异常
所以大家在进行指针操作的时候一定要小心,可以一疏忽你的指针就变成了野指针🗡
9、删除pos位置之前的结点【比较综合✔】
- 说完了pos位置之后的结点删除操作,那之前的一定也要说,和这个和插入一样,会有多种情况,可能会改变头结点,因此需要使用到二级指针
void SListEraseBefore(SLTNode** pphead, SLTNode* pos)
第一种:pos就位于头结点位置
- 这种情况的话是无法删除的,因为头结点前面为空,所以直接返回即可,当然你也可以assert
//当pos就为头结点时
if ((*pphead) == pos) {
return;
}
第二种:pos位于头结点的后一个位置,需要删除的便为头结点
- 这个的话就是我们前面讲到过的头删,直接调用即可
else if ((*pphead)->next == pos) {
SListPopFront(pphead); //头删
}
第三种:正常情况,但是比较繁琐
- 对于正常情况,其实是比较麻烦的,我们来看看
- 对于这种情况,因为我们是要去删除pos结点的前一个结点,但是我们知道,
要删除一个结点,就要找到它的前一个结点
,因此这个时候我们需要定义一个结构体指针cur,刚开始指向【*pphead】,在不断往后遍历的时候当【cur->next->next == pos】时,定义一个指针指向当前cur的next,进行一个保存,接着执行【cur->next = delnode->next】,便可以进行一个删除 - 具体代码如下
void SListEraseBefore(SLTNode** pphead, SLTNode* pos)
{
assert(pos); //删除结点要断言
assert(*pphead);
//当pos就为头结点时
if ((*pphead) == pos) {
return;
} //当删除的结点为头结点时
else if ((*pphead)->next == pos) {
SListPopFront(pphead); //头删
}
else {
SLTNode* cur = *pphead;
while (cur->next->next != pos)
cur = cur->next;
SLTNode* delnode = cur->next;
cur->next = delnode->next;
free(delnode);
}
}
- 我们来测试一下看看
10、释放链表
- 最后的压轴💃当然是给到Destroy,既然==Create了,那一定要Destroy==,有始有终,才是最好
- 首先说一下整体思路。对于单链表来说,它和顺序表并不一样,因为对于顺序表而言,是一块连续的存储空间,申请的时候是一整块一起申请,释放的时候自然也是一整块一起释放,因此直接free即可。但是对于链表不同,
链表的每一个结点在堆内存中的空间是随机申请的
。因此存储是不连续的,那对于释放来说,就需要从头结点开始,一一释放下去 - 首先还是一样,保留好头结点,拿一个指针代替,接着在释放头结点的时候还要先行保存其下一个结点,然后free掉当前的cur结点,让cur结点指向下一个结点,继续进行一个释放
- 看一下代码
void SLIstDestroy(SLTNode** pphead)
{
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* nextNode = cur->next;
free(cur);
cur = nextNode;
}
}
- 你觉得这样可以了吗?那有同学可以说我这么问肯定是不可以,那你知道缺了什么吗,我们将一个单链表Destory一下试试,然后将其打印出来再看看
- 是的,可以看到又是我们所熟悉的野指针,为什么呢?因为你将链表的头结点释放了,那这就是一块随机的地址,上面说到过,访问一块随机的地址,也就形成了【野指针】
【生活小案例5——利剑不锋利🗡】
- 上面又说到了这个野指针的问题,我们再来谈一谈,对于野指针,也是它就像是一把锋利的剑一样,非常危险,但是呢,它又不是随时都会有危险,因为当这把剑放在剑鞘中时,其实是非常安全的,并不是伤害到你,但是当你将它把出来的时候,那这个时候你就要小心使用了,一不留神可能就会伤到自己。
- 对于野指针也是一样,有的时候你知道这个指针可能会是野指针,但是你不去使用它访问数据,那其实是很安全的,不会有问题,但是当你使用到了这个野指针去访问的时候,其实就会非常危险了。所以在这里还是和大家说一句:==谨慎使用指针==
好,我们回归正题,既然这样会造成野指针,那应该怎么修改呢,那就是将和这个头结点指针置为NULL即可,这个时候再去打印的时候这个链表就是空的,也就不会出错了
四、整体代码展示【需要自取】
==SList.h==
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType;
typedef struct SListNode { //结构体大小:8B
SLTDataType data;
struct SListNode* next;
//SLT* next; 不可以这样定义,因为还没有到声明
}SLTNode;
SLTNode* BuySLTNode(SLTDataType x);
SLTNode* SListCreate(int n);
void PrintList(SLTNode* phead);
void SListPushBack(SLTNode** phead, SLTDataType x);
void SListPopBack(SLTNode** phead);
void SListPushFront(SLTNode** pphead, SLTDataType x);
void SListPopFront(SLTNode** pphead);
SLTNode* SListFind(SLTNode* phead, SLTDataType x);
void SListInsertAfter(SLTNode* pos, SLTDataType x);
void SListInsertBefore(SLTNode** pphead, SLTNode* pos, SLTDataType x);
void SListEraseAfter(SLTNode* pos);
void SListEraseBefore(SLTNode** pphead, SLTNode* pos);
void SLIstDestroy(SLTNode** pphead);
==SList.cpp==
#define _CRT_SECURE_NO_WARNINGS 1
#include "SList.h"
//-----------------
/*动态开辟结点*/
SLTNode* BuySLTNode(SLTDataType x)
{
//动态开辟
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail\n");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
}
//-----------------
/*建立链表*/
SLTNode* SListCreate(int n)
{
SLTNode* phead = NULL, * ptail = NULL;
for (int i = 0; i < n; ++i)
{
SLTNode* newnode = BuySLTNode(i);
if (phead == NULL)
phead = ptail = newnode;
else
{
ptail->next = newnode;
ptail = newnode;
}
}
return phead;
}
//-----------------
/*打印链表*/
void PrintList(SLTNode* phead)
{
//无需断言,链表为空,可以打印
SLTNode* cur = phead; //尽量不用头指针,定义变量存放【4 byte】
while (cur != NULL)
{
printf("%d->", cur->data);
//printf("[%d|%p]->", cur->data,cur->next);
cur = cur->next;
}
printf("NULL\n");
}
//-----------------
/*尾插*/
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
//无需断言,链表为空,可以尾插
SLTNode* newnode = BuySLTNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
//通过二级指针的解引用改变头结点【从NULL->newnode】
}
else
{
SLTNode* pptail = *pphead;
while (pptail->next)
{
pptail = pptail->next;
//无需再使用二级指针,无需改变头结点,只需要做一个链接
//改变结构体的指向,使用结构体指针
}
pptail->next = newnode;
}
}
//-----------------
/*尾删*/
void SListPopBack(SLTNode** phead)
{
assert(*phead); //必须断言,链表为空,不可尾删
if ((*phead)->next == NULL)
{ //若只有一个结点,直接将其释放,传入二级指针便改变了链表的头结点
free(*phead);
*phead = NULL;
}
else
{
SLTNode* ptail = *phead;
while (ptail->next->next != NULL)
{
ptail = ptail->next;
}
free(ptail->next);
ptail->next = NULL;
}
}
//-----------------
//头插
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
//无需断言,链表为空,可以头插
SLTNode* newnode = BuySLTNode(x);
newnode->next = (*pphead); //二级指针解引,变为一级指针
*pphead = newnode; //再让newnode变为新的头
}
//-----------------
//头删
void SListPopFront(SLTNode** pphead)
{
//必须断言,链表为空,不可头删
assert(*pphead); //删除都先断言一下,看看传进来的链表是否为空
SLTNode* next = (*pphead)->next; //先保存当前首结点的下一个结点
free(*pphead);
(*pphead) = next;
}
//-----------------
//查找
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
//while (cur != NULL)
while (cur)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
//返回的是指向这个待查结点的指针,并不是位置
}
//-----------------
//在pos位置之后插入结点
void SListInsertAfter(SLTNode* pos, SLTDataType x)
{
//0是假,非0才是真
assert(pos != NULL); //若不为空,则不会执行;若为空,则报出警告
SLTNode* newnode = BuySLTNode(x);
//pos->next = newnode;
//newnode->next = pos->next;
//pos的下一个位置就没了,相当于是newnode自己指向自己
newnode->next = pos->next;
pos->next = newnode;
}
//-----------------
//在pos位置之前插入结点
void SListInsertBefore(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
if (*pphead == pos) {
//若pos位置就为原先的头结点,则使用头插法进行插入
SListPushFront(pphead,x);
}
else {
SLTNode* cur = *pphead;
while (cur->next != pos)
{
cur = cur->next;
}
SLTNode* newnode = BuySLTNode(x);
//此处无需考虑前后错乱问题,直接链接即可
cur->next = newnode;
newnode->next = pos;
}
}
//-----------------
//删除pos位置之后的结点
void SListEraseAfter(SLTNode* pos)
{
assert(pos);
SLTNode* nextNode = pos->next;
if (nextNode == NULL){
return; //考虑到所查找到的结点为最后一个结点,后面无结点可删
}
else {
//free(pos->next); //会产生野指针
//pos->next = pos->next->next;
pos->next = nextNode->next;
free(nextNode);
}
//-----------------
//删除pos位置之前的结点
void SListEraseBefore(SLTNode** pphead, SLTNode* pos)
{
assert(pos); //删除结点要断言
assert(*pphead);
//当pos就为头结点时
if ((*pphead) == pos) {
return;
} //当删除的结点为头结点时
else if ((*pphead)->next == pos) {
SListPopFront(pphead); //头删
}
else {
SLTNode* cur = *pphead;
while (cur->next->next != pos)
cur = cur->next;
SLTNode* delnode = cur->next;
cur->next = delnode->next;
free(delnode);
}
}
//-----------------
//释放链表
void SLIstDestroy(SLTNode** pphead)
{
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* nextNode = cur->next;
free(cur);
cur = nextNode;
}
*pphead = NULL;
}
==test.cpp==
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <malloc.h>
#include "SList.h"
/* 顺序表缺陷
* 1.空间不够,需要扩容。扩容(尤其是异地扩容)是有一定代价的。其次还可能存在一定的空间浪费
* --》扩容都是扩2倍,扩得多了,一些不用空间的就会浪费
* --》扩得少了,插入一些数空间又不够了,又需要频繁的扩容
*
* 【吃米饭】一碗不够,吃两碗
* 吃一碗零20粒,频繁去锅里舀饭
*
* 2.头部或者中部插入删除,需要挪动数据,效率低下
*/
/* 优化方案
* 1.按需申请释放【要存储一个数据就开一块空间(结点)】
* 2.不要挪动数据【指针可以存放下一块空间的地址】
*/
/*
* 顺序表支持随机访问,可以根据下标快速访问到某个元素
* 链表不支持随机访问,只能通过头结点的next指针域一个个访问下去,最坏会到达O(N)
* 假设顺序表是货车,链表是公交车,都是不可替代的
*/
void TestSList1()
{
SLTNode* n1 = (SLTNode*)malloc(sizeof(SLTNode));
SLTNode* n2 = (SLTNode*)malloc(sizeof(SLTNode));
n1->next = n2;
SListNode n3, n4;
n3.next = &n4;
}
void TestSList2()
{
/**/SLTNode* n1 = BuySLTNode(1);
SLTNode* n2 = BuySLTNode(2);
SLTNode* n3 = BuySLTNode(3);
SLTNode* n4 = BuySLTNode(4);
n1->next = n2;
n2->next = n3;
n3->next = n4;
n4->next = NULL;
SLTNode* SList = SListCreate(5);
PrintList(SList);
}
void Swap1(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void Swap2(int** pp1, int** pp2)
{
int* tmp = *pp1;
*pp1 = *pp2;
*pp2 = tmp;
}
void test1()
{
int a = 2, b = 5;
//printf("a = %d, b = %d\n", a, b);
//Swap1(&a, &b);
//printf("a = %d, b = %d\n", a, b);
int* p1 = &a, * p2 = &b;
printf("*p1 = %d, *p2 = %d\n", *p1, *p2);
Swap2(&p1, &p2);
printf("*p1 = %d, *p2 = %d\n", *p1, *p2);
printf("a = %d, b = %d\n", a, b);
}
//尾插
void TestSList3()
{
//SLTNode* SList = SListCreate(10);
//PrintList(SList);
//SListPushBack(SList, 100);
//SListPushBack(SList, 200);
//SListPushBack(SList, 300);
SLTNode* SList = NULL;
SListPushBack(&SList, 100);
SListPushBack(&SList, 200);
SListPushBack(&SList, 300);
PrintList(SList);
}
//尾删
void TestSList4()
{
SLTNode* SList = NULL;
SListPushBack(&SList, 100);
SListPushBack(&SList, 200);
SListPushBack(&SList, 300);
PrintList(SList);
SListPopBack(&SList);
PrintList(SList);
SListPopBack(&SList);
PrintList(SList);
SListPopBack(&SList);
PrintList(SList);
//再多删一次
SListPopBack(&SList); //访问越界
PrintList(SList);
}
//头插 — 头删
//void TestSList5()
//{
// SLTNode* SList = NULL;
// printf("尾插:");
// SListPushBack(&SList, 100);
// SListPushBack(&SList, 200);
// SListPushBack(&SList, 300);
// PrintList(SList);
//
// SLTNode* SList2 = NULL;
// printf("头插:");
// SListPushFront(&SList2, 100);
// SListPushFront(&SList2, 200);
// SListPushFront(&SList2, 300);
// SListPushFront(&SList2, 400);
// PrintList(SList2);
//
// printf("头删:\n");
// SListPopFront(&SList2);
// PrintList(SList2);
// SListPopFront(&SList2);
// PrintList(SList2);
// SListPopFront(&SList2);
// PrintList(SList2);
// SListPopFront(&SList2);
// PrintList(SList2);
//}
//查找与插入
void TestSList6()
{
SLTNode* SList = NULL;
SListPushBack(&SList, 1);
SListPushBack(&SList, 2);
SListPushBack(&SList, 3);
SListPushBack(&SList, 4);
SListPushBack(&SList, 5);
PrintList(SList);
//SLTNode* pos = SListFind(SList, 3);
//if (pos != NULL) {
// printf("找到了\n");
// printf("pos = %d\n", pos->data);
// SListInsertAfter(pos, 9);
// PrintList(SList);
//}
//else {
// printf("没找到\n");
//}
//SLTNode* pos = SListFind(SList, 88);
//SListInsertAfter(pos, 9);
//PrintList(SList);
//SLTNode* pos = SListFind(SList, 3);
//SListInsertAfter(pos, 9);
//PrintList(SList);
//SListInsertBefore(&SList, pos, 11);
//PrintList(SList);
SLTNode* pos = SListFind(SList, 1);
SListInsertBefore(&SList, pos, 8);
PrintList(SList);
}
//删除之后的结点
void TestSList7()
{
SLTNode* SList = NULL;
SListPushBack(&SList, 1);
SListPushBack(&SList, 2);
SListPushBack(&SList, 3);
SListPushBack(&SList, 4);
PrintList(SList);
SLTNode* pos = SListFind(SList, 2);
SListEraseAfter(pos);
PrintList(SList);
}
void TestSList8()
{
SLTNode* SList = NULL;
SListPushBack(&SList, 1);
SListPushBack(&SList, 2);
SListPushBack(&SList, 3);
SListPushBack(&SList, 4);
PrintList(SList);
//SLTNode* pos = SListFind(SList, 4);
SLTNode* pos = SListFind(SList, 2);
//SLTNode* pos = SListFind(SList, 1);
SListEraseBefore(&SList, pos);
PrintList(SList);
SLIstDestroy(&SList);
PrintList(SList);
}
void test()
{
int a = 'o';
printf("%d\n", a);
}
int main(void)
{
//TestSList2();
//test1();
//TestSList3();
//TestSList4();
//TestSList5();
//TestSList6();
//TestSList7();
TestSList8();
//test();
return 0;
}
五、有关二级指针和断言的小结【⭐谨记⭐】
- 看完了上述有关单链表的所有操作,从这些代码中我们可以看到对于二级指针和断言的使用比较频繁,很多接口算法中都用到了这两个东西,但是对于二级指针和断言,并不是什么地方都要使用的。
- 仔细地分析一下就可以知道,对于==头插、尾插、头删、尾删这些操作,以及在pos结点之前插入和删除结点、销毁结点==,这些接口均需要使用到二级指针来接受链表的头指针,这是为什么呢?不知道大家有没有想过,那我在各个算法的讲解过程中其实都是从一级指针转换到二级指针,原因就是对于函数内部头结点的修改不会影响外部头结点指针的修改,而且我在二级指针引入那一块也说到了要改变【int】,就要使用【int*】;要改变【int*】,就要使用【int**】,也就是说我们要改变一级头结点指针就要使用二级指针去修改,对于上面这些接口操作,均需要修改头结点指针,因此便需要使用到二级指针去进行一个控制。
- 但是呢,并不是所有的接口都需要使用到二级指针,像打印、查找、在pos结点之后插入和删除结点这些操作,都是
不会修改头结点指针
的,所以我们只需要以及指针就可以了
- 然后再来说说断言的情况,对于断言,我们在接口的算法实现时也是用的即为频繁,相信在这么多算法看下来后,你对断言应该是有了一个认知了,当你程序出问题的时候,它可以帮你快速定位程序的异常之处,这个功能对于大多数的程序员来说可是一个福音,网上都流传着这么一句话【
一杯茶、一包烟、一个Bug改一天
】,这个话其实不无道理,因为在一些大型项目中,若是出现了一个Bug,因为很多代码逻辑都是串联的,可能你修改了这个Bug,另一个地方又出了Bug,这个时候其实assert断言就可以帮助到我们很多,尤其是对于一些==数组访问越界、野指针访问、空指针异常==这些,若是你一步步去调试的话其实是很难找出来的,那用assert就很香了,马上就给你报出来,而且哪一个【.cpp】文件的哪一行都会告诉你,于是程序员们直呼:assert实在是太棒了 - 但是对于assert我们真的可以什么地方都使用吗?当然不是
- 可以看到,我在很多
删除算法
的地方一般都会使用到assert,因为指针在不断前移或者后移的情况下可能造成越界访问,这个时候就会出现大问题了; - 还有一点的话就是我们通过找出来的【
pos
】位置去删除其之前或之后的结点,对于这个传进来的【pos】指针可能是一个空指针,我们知道对空指针进行操作是很危险的一件事,所以也需要断言一下; - 最后一点的话可能就是对于
二级指针接收的一级头结点指针
吧,对于传进来的头结点指针我们也需要进行一个判断,尤其是在尾删或者头删链表的地方,若是这个链表本身就是空的,那么如何对其进行一个删除呢?这其实也是一种对空指针的违法操作。有关如何断言的操作我在前面已经讲过了,如还有不清楚的再去看看
- 可以看到,我在很多
- 所以对于以上可能会出现问题的接口内部一开始的地方就要进行一个断言,但是除了这些地方其实有些不需要断言的地方你要平白无故地加上一个assert在前面,当你的同事看到这个断言的时候就会感到很诧异,例如说在pos位置前后去插入结点,这其实也没必要断言,因为可以进行头插和尾插,是不会出现非法操作的;
- ==在开发的过程中==,我们也需要考虑到用户的不当操作,然后作出一些提醒,但是对于像某些场景比如说用户在删除一个文件时可能是一时的不当操作,这个时候你就需要弹出一个
[对话框]
去告诉他是否需要继续进行操作,再让他确认一下,这样显得其实是比较合理的,但若是你直接在这个地方给出一个断言,判断用户执行了这个操作就直接报错,那用户可能就会被你吓到了,影响用户的体验感 - 所以说对于二级指针和断言,并不是什么地方都可以使用,我们应该在使用之前先做一个考虑和评估,看看在此处使用是否合理,真正地做到【因地制宜】,才能展现出逻辑型更强的代码:whale:
六、OJ题目实训
以下题目请通过我另一个专栏【LeetCode算法题解】观看📺——==题解更新中==.<{=....
【LeetCode】21 - 合并两个有序链表
【LeetCode】203 - 移除链表元素
【LeetCode】206 - 反转链表
【LeetCode】876 - 链表的中间结点
【牛客CM】11 - 链表分割
【牛客JZ】22 - 链表中倒数第k个结点
七、总结与提炼
最后,我们一起来回顾一下本文所学习的内容。除题解外,本文大概使用了
近四万字左右的篇幅
详细讲解了单链表相关的知识点
- 首先分析了顺序存储结构和链式存储结构各自的优缺点:对于【顺序表】,虽然可以做到随机快速访问,但是对于数据的插入和删除却很麻烦,
尤其是头插和头删
;对于【链表】,虽然在插入和删除这一块无需像顺序表那样,只要修改next指针域的指向即可,但是对于链表而言不支持随机访问,需要从头开始一一遍历才可访问到需访问元素
- 接着带着大家先行去初步了解了有关链表的结构,从逻辑结构和物理结构讲到了栈区和堆区的存储原理,进一步从底层的存储加深了对链表的了解
- 其次,开始了链表的各种接口算法实现,这一部分是==最核心,也是最重要的==,学了链表,最重要的是要学会如何去使用,那对于其创建、销毁、打印以及各种头插、尾插、头删、尾删等增删查改操作,其中的核心代码都是需要牢记心中:heart:
- 最后呢,也是带着大家做了一些在线OJ题目,来自【牛客】和【力扣】,这两个在线OJ网站还是不错的,推荐给大家,加深对知识的理解和掌握,这部分也是非常重要的,对于算法题,无论是在笔试还是面试中,也是除了基础知识以外面试官极为看重的一部分,所以大家要早早开始刷题
OK,以上就是本文所要讲解的全部内容,非常感谢您的观看,如有问题请于评论区留言或者私信我:hibiscus:
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
作者其他文章
评论(0)