序言
上一篇博客我总结了顺序表,顺序表是最基础的一个数据结构,其主要的底层逻辑就是数组,但是,他的缺陷也很明显。比如,在空间的开辟的时候,随着数据存储的越大,他所开辟的空间就会成倍数增长,最终导致较多空间的浪费;在插入数据的时候,每次都需要把数据往后进行挪动,如果数据是千万级的,那么其效率就会大大降低。因此链表的作用,就随着而来。
1.万物之基——结构体
对于链表来说,他的物理结构,就是一个接一个的节点通过指针来串联起来。那么链表中的节点又是什么?其实,链表中节点的本质就是一个个结构体,结构体里面存放了两个变量,一个是该节点的值,另一个是下一个节点的地址,因为要存放下一个节点(结构体)的地址,因而在创建节点的时候,是需要创建一个结构体的指针变量来进行存储的,结构体指针里既可以存放值,也可以存放地址。
以下是创建节点的代码:
typedef int SLTDataType;
typedef struct SLTist
{
SLTDataType x;
struct Slist* next;
}SLT;
在这段代码中,首先由于我们不知道未来节点(结构体)里要存放的是什么类型的变量,在这个地方,我们可以使用typedef来给我们要存放的变量进行重命名,在未来修改的时候,将会方便许多。而第二个变量就很明显,是一个结构体的指针,他将存放下一个节点(结构体)的地址。
2.链表中节点的创建
其实在链表里面,每一个节点都是由malloc函数进行动态内存的开辟得来的,因此,对于链表数据的存储,我们可以做到按需来开辟,也就是说,有多少需求,就可以创造多少个空间节点。因此,相较于顺序表,链表对于空间利用的优势,就在这里展现出来了。
代码如下:
SLT* SLTListBuyNode(SLTDataType x)
{
SLT* node = (SLT*)malloc(sizeof(SLT));
node->x = x;
node->next = NULL;
return node;
}
在这个代码中,我们最终的目标是要创建这个节点,因此创建完之后,是要返回这个节点的指针的。以方便后续的调用。
3.链表的尾插
要如何实现链表的尾插?
对于实现链表的尾插,首先要分清楚两种情况,一种是当前链表里面没有数据;第二种是链表里面有数据。
1.链表里面没有数据
假设我们要传进链表里面的是node节点。当链表里面没有数据的时候,这也就意味着传进尾插函数的指针当前为空指针,这个时候就没必要把空指针->next=node了,因为此时的指针为空,空的下一个仍然是空,这就会导致访问错误,无法找到空的next,vs系统会报错这是一个nullptr。
因此正确的做法是,直接把头指针赋给node,然后返回空就行了。
2.链表里面有数据
如果链表里面是有数据的话,这个时候就需要通过遍历链表来进行找尾,找到链表的尾部,然后用尾节点的next指向node,再用node的next指向尾节点的next(当然,这个地方也可以说用node的next指向NULL,所表达的意思都是一样的)。
在这个地方需要注意一下的是,链表的遍历和顺序表的遍历是非常不一样的。
在顺序表中,因为顺序表的本质仍然是数组,是可以通过下标来进
行访问的,因此是可以通过for循环来对顺序表进行遍历。
但是在链表里面,是有两种遍历的方式,第一种的遍历条件是node->next!=NULL,这种一般都会出现在找尾的场景下使用,第二种的遍历条件是node!=NULL,这种是在要寻找一个节点的场景下使用。
而在尾插的函数里面,是需要找到尾节点的位置,因而是需要用node->next!=NULL这个条件来加入循环中寻找。
那具体该如何去进行循环?思路就是新建一个结构体指针让他指向头节点,让这个新建的结构体指针来进行遍历。
void SLTPushBack(SLT** pphead, SLTDataType x)
{
SLT* node = SLTListBuyNode(x);
if (*pphead == NULL)
{
*pphead = node;
return;
}
if (*pphead != NULL)
{
SLT* pcur = (SLT*)malloc(sizeof(SLT));
pcur = *pphead;
while (pcur->next != NULL)
{
pcur = pcur->next;
}
pcur->next = node;
}
}
4.链表的头插
而对于头插而言,与尾插一样,也是需要判断链表里面是否是存在数据。
1.链表里面没有数据
如果链表里面没有数据,则直接把头指针赋给node就行了。
2.链表里面有数据
如果链表里面有数据,其操作的复杂程度还远不如尾插,整体思路就是把node->next指向头节点,然后再把头节点赋给node这个节点,使它称为新的头节点。
以下是代码:
void SLTPushFront(SLT** pphead, SLTDataType x)
{
SLT* node = SLTListBuyNode(x);
if (*pphead == NULL)
{
*pphead = node;
return;
}
if (*pphead != NULL)
{
SLT* pcur = (SLT*)malloc(sizeof(SLT));
pcur = *pphead;
node->next = pcur;
*pphead = node;
}
}
5.链表的尾删
尾删的思路也是非常的简单,首先找尾,然后释放。首先定义一个新的结构体指针,让他指向头节点,然后,通过这个新定义的结构体指针来进行对链表的遍历,前面我们说过,在链表的遍历中,如果想找尾,则可以利用node->next!=NULL这个条件来进行遍历的,当node->next为空的时候,则说明我们已经来到了链表的尾部。但是来到了这里,我们还不能直接把尾节点给释放掉,因为如果直接给释放掉,那么尾节点的前一个节点的next,就找不到了,他将指向的是一个未知的,混乱的东西。因为free的本质是把malloc等函数所开辟的空间给释放掉,是释放空间,并不是释放指针,空间都被释放掉了,那前一个节点的next又将指向什么东西呢?
因此,在释放掉尾节点之前,我们应该先处理好尾节点的前一个节点的next所指向的地方。因为在前面的创建节点的时候,我们把每一个节点的next都指向了NULL,如果有其它值,将会把这个NULL给覆盖掉,如果没有,则这个next仍然为空。
因此,在链表中,尾节点的next的指向是为空的,这个时候,我们只需要把前一个节点的next指向尾节点的next,然后再把尾节点给覆盖掉就可以了。
void SLTPopBack(SLT** pphead)
{
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
return;
}
SLT* ppre = NULL;
SLT* pTali = *pphead;
while (pTali->next!=NULL)
{
ppre = pTali;
pTali = pTali->next;
}
ppre->next = pTali->next;
free(pTali);
pTali = NULL;
}
在这个代码里面,值得一提的是,我在这定义了一个ppre的结构体指针,为什么要这么干?
因为如果不用他来记录pTail所走过的位置,那么即使找到尾,那也无法再找到尾节点的前一个节点了,这就是单链表的缺陷。而在代码里,pTail永远比ppre多走一步,当pTail停的时候,ppre所指向的刚好是pTail的前一个节点,这样,就方便的很多。
而assert这个函数的设计,是一种较为暴力的判断,如果assert里面的条件为假,则就触发判断,强制程序中断。在这个代码中,assert(*pphead)也可以写成assert(*pphead!=NULL),这两者的意思都是判断传过来的指针是否为空指针,如果是空,那么这个删除操作也就没有必要进行了。
6.链表的头删
头删的操作远远比尾删更简单,就是首先创建一个另外结构体指针变量pcur,把他的值指向链表的头节点,然后把指向头节点原来的指针向后挪动一位,是他指向自己的next,然后再把pcur给释放掉。
代码如下:
void SLTPopFornt(SLT** pphead)
{
assert(*pphead);
SLT* pcur = *pphead;
*pphead = (*pphead)->next;
free(pcur);
pcur = NULL;
}
这里面需要注意的是,*pphead = (*pphead)->next;这行代码中,为什么在右边需要对*pphead括起来?不括会怎样?
报错~
为什么?因为在c语言中,->操作符的优先级比*号要高,我们想要的是先把pphead解引用之后再对里面进行取值,但是由于->操作符的优先级比*号要高,因此他首先对pphead进行取值,但pphead并不是一个结构体,所以就取值失败。因此要用括号把*pphead括起来进行解引用。
7.在链表里面寻找节点(单纯寻找)
这个就太简单了,除了循环条件那需要注意一下。前面说过,node!=NULL,这种是在要寻找一个节点的场景下使用。
不想多说,直接上代码
void SLTFindNode(SLT** pphead, SLTDataType x)
{
SLT* find = *pphead;
while (find->next != NULL)
{
if (find->x == x)
{
printf("找到了,是%d", find->x);
}
find = find->next;
}
printf("找不到\n");
}
8.在指定位置的前面插入数据
1.什么是"指定位置"?
在实现这个方法之前,我们首先来探讨一下,这个"指定位置"指的是什么意思,是一个节点?还是第几个节点?如果是前者,那么就是一个结构体指针变量;如果是后者,则是一个整型变量。
实际上,上面的两种说法都可以是"指定位置"。但为了程序的效率以及可读性,在这里,就使用前者这个概念。
如果采用了前者这个方案,那么首先就要在链表中找到这个节点,就需要定义一个专门来寻找这个节点的函数,其返回值就是结构体指针,也就是返回一个节点。
那怎么实现呢?首先我们可以把想要查找的值给传进去,注意,是值,而不是节点,然后在链表里面进行遍历,如果发现了某一个节点中有这样的一个值,那么就返回这个节点。而未来这个返回的节点,将在实现"指定位置前插入数据"中起到重要的作用。
代码如下:
SLT* SLTListFind(SLT** pphead, SLTDataType x)
{
assert(*pphead);
SLT* node = *pphead;
while (node)
{
if (node->x == x)
{
return node;
}
node = node->next;
}
return NULL;
}
解决了寻找节点的问题,接下来就可以进行插入操作了。
由于我们这里是想在"指定位置之前"插入数据,因此,我们的遍历条件可以是node->next!=pos,如果发现了是相等,那么就找到了这个节点,此时此刻的node->next正好是位于pos的前面,这个时候的node->next就可以指向即将插入进来的新节点,新节点的next就可以指向pos
代码如下:
void SLTInsertFrontPos(SLT** pphead, SLT* pos, SLTDataType x)
{
assert(pos);
assert(*pphead);
SLT* node = SLTListBuyNode(x);
if ((*pphead)->next==NULL)
{
node->next = *pphead;
*pphead = node;
}
SLT* pcur = *pphead;
while (pcur->next != pos)
{
pcur = pcur->next;
}
node->next = pos;
pcur->next = node;
}
9.链表的打印
对于链表的打印,我想都到这里了,估计也是差不多明白他的循环条件了吧?
直接上代码:
void SLTPrintf(SLT** pphead)
{
SLT* node = *pphead;
while (node)
{
printf("%d->", node->x);
node = node->next;
}
printf("\n");
}
10.对于一些细节问题的深究
1.关于头删和尾删,他们为什么能删掉节点?
在我写的代码里面可以很清楚的看到,当我想删掉一个节点的时候,我通常会创建一个新的指针,让那个指针去遍历整个链表,然后释放,以达到我想要删除某个数据的目的。
就拿头删来距离,我在这里释放掉的明明是pcur呀,为什么会把*pphead给释放掉了?
从代码的表面上来看,STL*pcur = *pphead就好像是一次赋值,只是把*pphead的值赋给了*pcur,我把*pcur给释放掉了,跟*pphead有什么关系?
这个问题一开始对于我这种基础不太好的人而言,也是感到非常地困惑的。
后来我通过调试发现,当free这条语句释放掉之后,*pcur的空间和*pphead的空间都发生了改变
后来就想明白了,可以说,我所创建的*pcur他仅仅是一个指针
这个指针可以叫任何的名字,他们都共同指向一个节点,当执行free这条语句的时候,他释放的是指针所指向的结构体空间,在这里,指针仅仅是一个媒介,通过这个媒介,free语句才能释放掉该释放的空间,并不是释放指针这么简单。也并不是简单的赋值。
2.为什么要传二级指针?传一级指针不行吗?
对于这个问题,我们首先要明白,链表,他是环环相扣的,他每一个节点里面,都存放着下一个节点的地址,着也就意味着,他并不是一个简单的结构体能存放的,必须得创建结构体指针来存放他。
此时创建的是一个结构体的指针,那么如果你想改变一个指针的值,首先就要传这个指针的地址,那么你就需要用到一个变量来存放这个指针的地址,这个时候就需要用到二级指针来接收了。