数据结构之线性表中的单链表【详解】

简介: 前言:一、单链表1.单链表和顺序表的优缺点2.单链表的概念和学习3.单链表的各个接口的实现(详解每一步)3.1.先铺垫一下大致的思路3.2.然后这边我们看一下我们大致要实现的函数有哪些3.3.接下来我们就开始实现这些代码,并且附上详细的解释(注释5000字真的很详细)3.3.1.首先就是打印接口3.3.2.尾插接口3.3.3.新增结点接口

前言:

北京时间 2022/11/26 星期六的下午,神奇学校核酸4天3检,非常打乱时间安排,今天这篇博客的产出算是我从核酸排队时间中挤出来的(在这首先得感谢帮我解决了小问题的网上大佬,还有就是我们班班长的宽容),并且在我们学完顺序表之后一定要自己去想,自己去实现一遍(单链表也是),只有这样你才可以学得更加的扎实一些,所以有觉得自己顺序表还不熟的同学,可以回去再把顺序表复习一下。


一、单链表

1.单链表和顺序表的优缺点

1.1.首先我们讲一下单链表和顺序表各自的优点和缺点,从而引出我们今天的主题单链表


1.1.1.顺序表的缺点:1.空间不够了,需要扩容,扩容是有消耗的 2.头部或者中间位置的插入删除,需要挪动,挪动数据也是有消耗的(效率很低)3.避免频繁的扩容,一次一般都是按倍数进行扩容,可能存在浪费

1.1.2.顺序表的优点:1.支持随机访问(随机访问的好处:有些算法,需要结构支持随机访问,比如:二分查找、优化的快排等等)


1.1.3.单链表的缺点:1.每一个数据,都要在存一个指针去链接后面的数据结点 2.不支持随机访问,就是:要访问最后一个数据,我不能直接访问,我需要一个一个的向后链接的同时到了我需要的那个数据才可以访问(但是如果是顺序表就可以直接用下标直接访问第i个)

不好理解就以图为例,如图:


102.png

102.png

1.1.4.单链表的优点:1.按需申请空间,不用了就释放空间(更加合理的使用空间) 2.头部中间插入删除数据,不需要挪动数据 3.不存在空间浪费

2.单链表的概念和学习

2.1.所以此时我们针对着顺序表的缺点我们就设计出了单链表,当然单链表也是有很多缺点的,所以又设计出来双链表和循环链表等等,所以没有东西是完美的,有利有弊(但是只要我们思想不滑坡,办法总比困难多)


2.2.但是我们不应该想着不需要学习顺序表和链表而直接去学那种优点大于缺点的数据结构,(就像是加法都还不会,自然不能想着学乘法是吧),所以我们应该从简单的问题出发,打好基础,才可以完成以后的更高级的数据结构(二叉树,之类的)


2.3.所以我们这边就开始学我们的单链表:但是其实我们以前在学结构体的时候,那时候我们涉及过一些的有关链表的知识,但是只是知道有这个东西,实现起来还是有一些的绕的,所以我们在解题或者是实现接口的时候(最好是要话一个图出来,这样可以更好的利于我们解题),然后等我们学到了更高级的数据结构的时候就更应该画图(画图就是在帮助我们自己走出苦海,解救我们的生命,切记!切记!)


2.4 单链表的概念:就是通过创建结构体,使结构体中的成员变量中有一个指针变量,通过这个指针变量对每一个结构体(结点)进行链接在一起-》这就是所谓的链表,这个是逻辑方法以指针为参考的理解方法,然而在实际的内存之中却是另一种方法存在,意为物理方法:内存中的真实存储


2.4.1.逻辑方法图:

103.png

2.4.2.物理方法图:

104.png


2.5.并且栈上的内存是我定义一个有一个的,所以还是属于静态的但是此时我想有动态的(所以我的链表其实本质上还是向堆上申请空间的,插入一个数据我就开一个结点,插入一个数据我就开一个结点)所以数据结构在申请内存的时候都是在堆上申请的(链表自然也是),所以我们的链表的内存是从推上获取的


3.单链表的各个接口的实现(详解每一步)

介绍完了基本的概念我们这边就开始实现接口

3.1.先铺垫一下大致的思路

首先不管你学什么鬼,我们第一步还是要定义一个结构体出来


105.png


这个就是一个较为完整的结构体的定义,详解见顺序表中的结构体的定义,这边我们就不讲了

3.2.然后这边我们看一下我们大致要实现的函数有哪些

如图:

106.png

3.3.接下来我们就开始实现这些代码,并且附上详细的解释(注释5000字真的很详细)

3.3.1.首先就是打印接口

void SListPrint(SLTNode* phead) //此时的这个phead的意思就是我这个链表的指向第一个结点的指针(所以叫头指针的意思)
{
  //下面这步的详细理解就是:此时我的phead是一个结构体指针,此时这个指针指向了我的第一个结点(就是第一个结构体(也就是第一块空间)),然后我用cur这个指针去指向phead这个指针
  //的意思其实就是此时cur也跟phead一样也指向了第一个结点(原理就是:指针指指针得到的是两个指针指向了同一块空间),所以此时cur也指向了第一个结点
  SLTNode* cur = phead;  //在写链表的时候第一步就一定要拿创建一个同类型的指针来指向我们的头指针(然后利用这个指针来进行一系列指针的相互关联)
  while (cur != NULL)
  {
    if (cur->next != NULL)//这个条件的设定就是为了把最后一个数据后面的->去掉,因为不好看
    {
      printf("%d->", cur->data);
    }
    else if(cur->next == NULL)
    {
      printf("%d", cur->data);
    }
    //下面这步的理解有两种方法(逻辑方法,物理方法),逻辑方法就是指针指向,物理方法就是空间的存储
    cur = cur->next;//反正我的数据能够连续的存储,靠的就是这步(有了这步此时的cur就像是有了翅膀的鸟,就可以随便飞来飞去了,就可以不断的向后指向我的data数据)
    //这步的具体理解不仅要理解它自己(还要把next这个指向结构体的指针拿过来一起理解,根据上面我对next指针的理解:next指针就是一个指向下一个同类型结构体的一个指针(然后这个next指针指向的那个同类型的结构体中又有一个next指针,此时这个next指针就像是data一样,data有几个此时我的next指针就有这个))
    //所以此时可以把我的next指针等价于我的data数据,我想让这些data数据连续存放,我首先就要让我的next指针变的连续起来,所以此时就引入了一个cur指针(所以这个指针的作用就是让我的next指针变的连续(起了一个媒婆的作用),所以有了这个cur指针),我就可以把我的next指针赋给它,此时的这个cur(就跟data数据一样有了无数个,想指向那个结构体就指向那个结构体,想指向那个数据,就指向那个数据)
  }
}

不理解就看注释,注释很详细

3.3.2.尾插接口

void SListPushBack(SLTNode** pphead, SLTDataType x)//此时这个函数的意思就是我想在这个SList链表后面再插入一个结点(也就是再插入一个结构体的意思),但是为了学数据结构以后的结构体就理解成一个结点就行
{
  assert(pphead);
  //这边会涉及到一个二级指针的问题(主要就是关于参数传递时的那些事)(例如:我想传参的同时可以让外部影响到我的内部参数,此时我就要进行一个地址的传递)
  //所以这边就涉及到如何传地址,例如:我想改变整形(就把这个存放整形的变量以地址的形式传给函数调用),我想改变一个指针(此时就要把这个指针变量的地址以地址的形式传给函数调用),只有进行这样的传参才可以让函数内部的变量改变我函数外部的变量
  //所以此时就是利用这个开辟结点这个函数帮我么弄一个新的结点出来
  SLTNode* newnode = GreateListNode(x);
  if (*pphead == NULL)
  {
    *pphead = newnode;//这步的意思就是表示,此时我的phead已经是一个空指针了,此时里面什么东西都没有时,就刚好可以直接指向我新开辟出来的这个结点,这样它就有东西了
  }
  else
  {
     //这边讲到为插数据的关键(就是尾插时一定要让此时的指向尾的指针指向我想要插入的那个结构体数据,然后把这个我新插入的数据赋值成NULL ,这样才顺利的完成了我的尾插工作)
       //所以在我们进行尾插时(第一步就是应该要先找到我们此时的尾是那个数据(以NULL空指针作为判断条件))
       //然后为了找到我的尾,此时话不多说,我们应该要先定义一个指针出来指向我的第一个结点(然后因为这个指针几乎每一次都要使用(所以为了防止重定义,这边就换了一个名字而已),不然就和上面那个cur指针一样,就会导致重定义,仅此而已)
    SLTNode* tail = *pphead;//此时的这个*号就是表示因为此时传参传过来的是一个地址,所以此时我一定要加上这个*号(表示解引用的意思),只有这样我才可以让我的tail指针指向这块空间(而不是地址(如果是地址就出问题了,就会变成地址存地址(那就完蛋),所以必须是地址和空间,因为指针的本质上就是一个地址嘛))
    while (tail->next != NULL)//找尾
    {
      tail = tail->next;//这个还是那个意思(给鸟加一个翅膀而已)(其实就是把next赋给tail的意思,不要给这句tail->next吓到)
    }
    //tail = newnode->next;这样写就不对了,因为这样写我拿到了就不是newnode这个结构体的地址,拿到的就是后面的地址了,所以不能这样写
    tail->next = newnode;
  }
}

3.3.3.新增结点接口

SLTNode* GreateListNode(SLTDataType x)
{
  //所以此时就开辟一个新的结点(也就是开辟一个新的结构体出来的意思)
  SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//这步的这个计算大小sizeof要注意一下(我此时结点是一个结构体,所以此时是开辟一个结构体的大小,所以sizeof里面是一个结构体变量,不敢天天都是int变量)
  //新的结点开辟出来之后,就是对这个结构体中的变量先进行我需要的处理,但是在进行对这个空间处理的情况下,一定要判断一下这个空间到底有没有开辟成功
  if (newnode == NULL)
  {
    printf("malloc fail\n");
    exit(-1);//表直接退出
  }
  newnode->data = x;  //这个就是表示此时结构体中的数据
  newnode->next = NULL;//这个就是表示此时结构体中的那个指针
  return newnode;
}

3.3.4.头插接口

void SListPushFront(SLTNode** pphead, SLTDataType x)
{
  assert(pphead);
  //此时这个不管是头插还是尾插此时都是要新增加一个结点出来的,所以此时如果每一个函数都写(就很重复的感觉,所以此时把新增结点给弄成一个函数,这样就很完美)
  SLTNode* newnode = GreateListNode(x);
  newnode->next = *pphead;//这个就是把此时的头指针的地址给newnode这个结点中的next指针(这样此时的newnode中的next指针就成为了头指针)
  *pphead = newnode;//这个的意思就是把newnode的地址给pphead这个指针(因为此时的pphead已经不是头指针了,是第二个结点的指针,所以此时第二个结点中的指针就应该要存放头结点的地址)
}

3.3.5.头删接口

void SListPoptFront(SLTNode** pphead)//此时根据图(或者说是根据原理)可以得到此时的头删函数不管是一个结点还是多个结点,此时都可以很好的完成任务(唯独只有当没有结点的时候不能完成任务),所以此时还要给一个不为空的条件
{
  assert(pphead);
  if (*pphead == NULL)
  {
    printf("node is empty");
    return;
  }
  else
  {
    SLTNode* next = (*pphead)->next;//这边要注意优先级,所以要给一个括号
    free(*pphead);
    *pphead = next;//这个就是表示把此时的头指针赋给*pphead这个指针
  }
}

3.3.6.尾删接口

void SLTistPoptBack(SLTNode** pphead)
{
  assert(pphead);
  //此时分类讨论(三种情况讨论)
  if (*pphead == NULL)//我选assert(*pphead != NULL)
  {
    return;
  }
    if ((*pphead)->next == NULL)//这个就是表示此时只有一个结点,此时就是一个头删(所以可以直接进行free释放掉赋值成NULL就行了)
  {
    free(*pphead);
    *pphead = NULL;
  }
  else
  {
    SLTNode* prev = NULL;
    SLTNode* tail = *pphead;
    //找尾
        //1.
    while (tail->next != NULL)
    {
      prev = tail;
      tail = tail->next;
    }
    while (tail->next != NULL)//while(tail->next);一个道理(更简便)
    {
      //判断
           //if (tail->next != NULL)//这个表示的是我已经找到最后一个结点了(因为只有最后一个结点中的指针是NULL空指针)(但是只要自己明白就行了,不敢写出来)
      prev = tail;//每次把我的位置赋给这个指针,然后再进行移动
      tail = tail->next;
    }
    //  //这边不敢这样写,这样写就会导致把最后一个结点的data数据给变成一个随机值(然后我此时的上一个结点中的那个指针:就会变成一个野指针)
    //  /*free(tail);
    //    tail = NULL;*/   //一定要把移动前的位置赋给另一个指针,让那个指针最后成为尾,不然就不能这样写
    //    //更不敢这样写,此时的这个next本来就是一个NULL,你free它不还是一眼
    //    //free(tail->next);
    //    //tail->next = NULL;
    //    //所以应该按下面这样写(再使用一个指针来记录我此时的位置)
    //    //并且此时如果上面那个循环结束就表示已经找到了
    {
      free(tail);
      tail = NULL;
      prev->next = NULL; //这个就是表示尾结点的上一个位置,然后此时把这个结点的指针赋值成NULL,就表示此时它变成了我的尾结点
    }

3.3.7.查找接口

SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
  SLTNode* cur = phead;
  while (cur)
  {
    if (cur->data == x)//表示找到了
    {
      return cur;//就返回此时这个指针(只要有这个地址的返回就一定还要有 SLTNode* 或者 int* 反正就是一点要有一个* 表示此时是一个地址,然后类型最好是用结构体类型)
    }
    else//此时就是表示第一次找的时候并没有找到这个结点中的数据
    {
      //那么就应该继续往后找
      //所以这变就是写
      cur = cur->next;
    }
  }
  //这个表示到最后都找不到,就是无这个结点数据
}

3.3.8.任意pos位置插入接口

void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
  assert(pphead);
  assert(pos);
  //首先不啰嗦就是开辟一个新的结点出来
  SLTNode* newnode = GreateListNode(x);
  if (*pphead == pos)//此时的这个意思就是说明,如果pos是第一个元素,就说明是头插
  {
    //下面这个就是一个头插的意思
    newnode->next = *pphead;
    *pphead = newnode;
  }
  //找到pos的前一个位置(为什么要找pos的前一个位置呢?原因就是:我要在pos这个位置插入一个数,那么我就要有pos和pos前一个两个结点,才可以进行插入点操作)
  //这样才可以很好的实现指针的交换,进而实现位置的插入(所以我应该要怎样去找呢?就要使用找尾的那种方式去找(条件为pos))
  else
  {
    SLTNode* posPrev = *pphead;
    while (posPrev->next != pos)//这步就是在判断如何找我的pos前面的那个结点(一定要理解我这个判断条件的意思,意思就是pos前面那个结点的next指针其实是指向第三个结点的,所以只要当这个next指针=pos时,就是说明了此时我找到了pos前一个结点,此时循环停止)
    {
      //posPrev = pos->next;这步很关键,这步不敢写成这个样子(要去真正的理解这步的意思,这个指针的指向的地址一定要搞明白来)
      posPrev = posPrev->next;//这步的理解一定还要去结合上SLTNode* posPrev = *pphead;这步的理解才行,因为这个东西是在一直的往后走,此时就有点像是尾插里面的这步tail = tail->next;就是给鸟来一个翅膀而已,让它可以动起来
    }
    posPrev->next = newnode;//这两步就是在进行我的结点的交换
    newnode->next = pos;
  }
}

3.3.9.任意pos后位置插入接口

void SListInsertAfter(SLTNode** pphead, SLTNode* pos, SLTDataType x)//此时是因为在pos的后面插入,所以此时这个函数不需要把第一个结点的地址传过来给我
{
  assert(pphead);
  SLTNode* newnode = GreateListNode(x);
  newnode->next = pos->next;//这步是为什么呢?原因就是:此时的pos的next指针指向的就是下一个结点的位置,所以此时就是先把下一个结点的位置赋给我的新结点,这样就可以在pos的后面新插入一个结点了,(然后此时再让这个新结点去指向我的刚刚的pos后面的那个结点,此时刚刚pos后面的那个结点就变成立第三个结点,所以我就成功的在pos后面插入了一个结点)
  pos->next = newnode;//所以此时可以看出我的这个pos->next指针是非常有用的(因为它不仅要可以帮我找到pos后面的那个位置,还可以存放此时的新结点的空间的位置),有两个重要作用
  //此时有了这个新结点,我们就可以开始操作了
  //因为两种不同的情况,所以我们先判断
}

3.3.10.任意pos位置删除接口

void SListDele(SLTNode** pphead, SLTNode* pos)   //时间复杂度:O(N)    //不好效率太低,所以删除后一个就会更加的合适
{
  assert(pphead);
  assert(pos);//这两个位置就都是为了防止别人用错了(传过来一个空地址或者传过来一个空位置,所以都可以进行断言)
  if (pos == *pphead)
  {
    SLTNode* next = pos->next;
    free(*pphead);
    *pphead = next;
  }
  else//也就是我删的结点是中间或者最后一个的(非头)
  {
    //按照原理:我应该就一定要找到我pos的前面的那个结点,才可以删(显然如果我不找到pos前面的那个结点,此时我肯定是无法继续将我的链表链接起来的,所以一定要找到)
    SLTNode* tail = *pphead;
    //while (tail->next->next != NULL)//这步不敢这样写,这样写就是在找尾的过程(因为此时我要找到的是pos的前一个,不是尾的前一个,所以这边这样写肯定是有错的)
    while (tail->next != pos)//这步的意思就是:在找pos的前一个结点
    {
      tail = tail->next;//表示没找到就一直找
    }
    //找到了就来到这个位置,进行操作:
    tail->next = pos->next;//这步的意思就是:把pos后面的那个结点赋给pos前面那个结点(这样pos就可以滚了(没有此时pos这个位置我的链表也还可以链接在一起))
    free(pos);
    pos = NULL;//好习惯
  }
}

3.3.11.任意pos位置后删除接口

void SListDeleAfter(SLTNode* pos)
{
  assert(pos->next);
  assert(pos);
  //1.自己的写法(都是可以的)
  SLTNode* next = pos->next->next;
  free(pos->next);
  pos->next = NULL;
  pos->next = next;
  //SLTNode* next = pos->next;
  //pos->next = next->next;
  //free(next);
  //next = NULL;
}

3.3.12.销毁接口

void SListDestroy(SLTNode** pphead)
{
  //此时的这个销毁并没有想象的那么的简单,因为这个涉及到内存泄露的问题
  //原因:
/*  free(*pphead);
  *pphead = NULL;*///如果直接这样写,就会有问题(问题:我只释放了头结点的空间,头结点后面的空间我并没有释放掉,所以造成了内存泄露)
  //所以在释放内存的时候,我用我的屁屁想,这里也要给一个循环
  SLTNode* cur = *pphead;
  while (cur != NULL)
  {
    SLTNode* next = cur->next;//反正总的来说就是我们要一个一个的释放,然后每次释放前都一定要把下一个结点的位置先给保留下来,不然就找不到下一个结点,就内存泄露
    free(cur);
    cur = next;
  }
  *pphead = NULL;
}

这边我们并没有具体的去实现菜单函数和整体的一个功能(感兴趣可以自己尝试)

4.整体图片如下

(1.)接口图片如下

image.png

(2.)调试图片如下:

image.png


把一个一个功能分开调式,方便测试自己的代码到底是哪里出了问题,方便我们解决问题

相关文章
|
6天前
【数据结构】单链表(长期维护)(1)
【数据结构】单链表(长期维护)(1)
|
6天前
|
存储
数据结构中的 线性结构和非线性结构
这篇文章介绍了数据结构中的线性结构和非线性结构,其中线性结构包括顺序存储结构和链式存储结构,如数组、队列、链表和栈;非线性结构包括图结构、树结构、二维数组、广义表和多维数组。
|
11天前
|
存储
【数据结构】单链表-->详细讲解,后赋源码
【数据结构】单链表-->详细讲解,后赋源码
15 4
|
1天前
|
算法 索引
【初阶数据结构篇】单链表算法题进阶
深拷贝应该正好由 n 个全新节点组成,其中每个新节点的值都设为其对应的原节点的值。
|
6天前
【数据结构】单链表(长期维护)(2)
【数据结构】单链表(长期维护)(2)
|
1月前
|
存储 DataX C语言
【数据结构】单链表
数据结构中的单链表
18 0
【数据结构】单链表
|
2月前
|
存储 测试技术
【数据结构】操作受限的线性表,栈的具体实现
【数据结构】操作受限的线性表,栈的具体实现
36 5
|
2月前
|
存储 测试技术
【数据结构】操作受限的线性表,队列的具体实现
【数据结构】操作受限的线性表,队列的具体实现
29 4
|
2月前
|
算法 程序员 数据处理
【数据结构与算法】使用单链表实现队列:原理、步骤与应用
【数据结构与算法】使用单链表实现队列:原理、步骤与应用
|
2月前
|
算法 C语言
【数据结构与算法 经典例题】返回单链表的倒数第 k 个节点
【数据结构与算法 经典例题】返回单链表的倒数第 k 个节点