上一篇文章(http://t.csdnimg.cn/r68bL)我们讲解了数据结构中的顺序表,接下来我们将讲解单链表。
一、顺序表的缺陷
在我们实现顺序表的一些操作时,我们会发现顺序表有很多缺陷:
1.空间浪费。比如我们将动态顺序表增容时,我们需要申请2-3倍的新空间,但是我们不可能保证扩充的空间可以被完全利用,会造成浪费。比如我们的顺序表的容量为100,我们增容两倍变成200,但是我们实际上只使用几个空间,那剩下的90多空间就是浪费了。
2.消耗过大。增容要申请新的空间,拷贝数据,释放旧空间,这样会造成不小的消耗。
3.时间复杂度很大。当我们进行头插头删以及特定位置的插入和删除时,我们需要进行的操作是,先将该位置后的数据都往后挪动一个空间,再插入数据(或先删除数据,再将后面的数据往前移动),如果顺序表的数据量很小,可能差异不大,当项目中数据量很大时,假如有N个数据要挪动,那么它的时间复杂度就是O(N)。将数据一个一个往后或往前挪动是很繁琐,没有效率的操作,会大大降低运行效率,增加运行时间。
二、单链表简介
那么我们该如何解决这些问题呢?对于第一个和第二个问题,都是关于增容的问题,那么在我们需要更多空间存放数据时,能否不使用翻倍扩容操作?而是需要多少数据,就申请多少空间?当我们插入或删除数据时,能否不对其他数据进行操作?直接插入到表中,提高运行效率呢?答案就是链表,今天我们先讲解单链表。
链表是一种物理存储结构上非连续,非顺序(非线性)的存储结构,数据元素的逻辑结构是通过链表中的指针链接次序(线性)实现的。其中物理结构上非连续,指的是链表是由一个个的节点组成,节点的地址是不连续的,而不是像顺序表一样有连续的空间存放数据。但是我们仍然可以在逻辑上将链表看成由一个个节点组成的线性结构,节点之间由指针连接。因此链表也是线性表的一种。下面是顺序表和单链表的表示图。
既然链表是由一个个的节点组成,那么节点是由什么组成?和顺序表相比,顺序表是一整块存放数据的连续空间,而链表是由一个个的节点组成。顺序表中单个数据就是顺序表中某个取下标的值,而链表中单个数据时存放在一个节点中,那么节点中除了存放数据,还应该存放什么?我们如何通过一个节点找到下一个节点?即结构体内存放指向下一个结构体的地址,所以我们需要存放一个指针,指针的返回类型为结构体类型。单链表节点的代码表示如下:
typedef int SLTDataType;//类型重命名,方便之后替换
//节点的结构
//数据+指向下一个节点的指针
typedef struct SListNode
{
SLTDataType data;//节点自身的数据
struct SListNode* next;//用于指向下一个节点(结构体),通过指针(地址)才能找到下一个节点
}SLTNode;
三、单链表的基本操作
1、打印单链表
打印单链表有助于我们直观地观察链表中的数据。打印链表时,我们要将链表的地址传过来,地址不能为空,从第一个节点开始打印数据,打印完后将该节点的next指针存储的下一个节点的地址赋给指向该节点的pcur指针,pcur指针就会指向下一个节点,这样我们就将两个节点连接起来了,用这种方式实现遍历。
void SLTPrint(SLTNode* phead)//传递节点的地址
{
assert(phead);
SLTNode* pcur = phead;//创建临时指针pcur指向phead
while (pcur)
{
printf("%d->", pcur->data);//打印每个节点中的数据
pcur = pcur->next;
//将自身节点中指向下一个节点的指针赋给pcur本身,存储下一个节点的地址,通过这种方式向后递推链表
}
printf("\n");
}
2、申请节点
申请节点的操作很简单三个步骤,第一步就是申请一块空间,第二步就是对节点的数据初始化,第三步就是对节点的next指针初始化。我们可以手动申请节点,但是在进行头插尾插等操作时,我们没必要再去手动申请了,可以将其写成一个常用的函数。
//手动申请
SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));//申请一个节点大小的空间,类型为结构体指针
node1->data = 0;//数据初始化
node->next=NULL;//指针初始化
//函数
SLTNode* SLTBuyNode(SLTDataType x)//x用于初始化链表内的数据
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//申请空间
if (newnode == NULL)
{
perror("malloc fail!");//申请空间可能会失败,失败就退出
exit(1);
}
newnode->data = x;//数据初始化
newnode->next = NULL;//申请成功后赋初始值为空
return newnode;//返回一个节点
}
3、尾插与头插
和顺序表一样,我们如何插入新的数据呢?顺序表是直接在数组中插入数据,而链表则是要申请一块新的空间,即一个节点来存放数据,除此之外,我们还要考虑节点中Next指针的指向。我们先来看尾插的思路。
1、尾插
我们需要在原链表末尾插入一个节点,首先我们先分析原链表,原链表为空链表以及不为空的情况。如果是空链表,相当于我们要插入的这个节点就是新的头结点,如果不为空,那么我们只要找到尾节点,再让尾节点的next指针指向新节点就行了,下面看一下代码
void SLTPushBack(SLTNode** pphead, SLTDataType x)//
{
assert(pphead);
//不能对一个空指针进行解引用,传进来的地址不能为NULL
//*pphead可以为空,说明这个指针指向的地址为空,没有指向节点
SLTNode* newnode = SLTBuyNode(x);//申请一个新的节点
//指针存储空链表情况与非空链表情况
//要对结构体指针*pphead解引用时,我们需要先确定该指针是否为空
if (*pphead == NULL)
{
*pphead = newnode;//如果*pphead存储的是空链表,我们就不需要找尾了,新节点作为头节点(取代空链表)
}
else {
//找尾巴
SLTNode* ptail = *pphead;
//用临时结构体指针寻找尾节点,从头节点开始沿着链表的各个节点遍历
//尾节点的标志:pcur->next==NULL;
while (ptail->next)
{
ptail = ptail->next;
}
//找到尾节点:ptail->next=NULL;
ptail->next = newnode;//使尾节点的next指针指向新节点
}
}
2、易错点:为什么要用二级指针传参?
相信这里有很多人都对这里为什么用二级指针传参感到困惑,我学习的时候也很疑惑,好像明明将链表头节点的地址传进来,就可以进行操作了啊,但是当我第一次用一级指针SLTNode* pphead传参,进行测试并调试的时候发现了问题。
我们可以看到,作为形参的pphead确实执行了函数中链表为空情况的操作,将我们新申请的newnode节点赋给了形参pphead,pphead尾插成了空链表的第一个节点。但是我们的实参并没有发生变化,仍然是NULL。这就和我们学指针传参时所学的用指针传递实参而不用int等类型传参一个道理了。这就说明了我们并没有进行址传递,而是进行了值传递。那么为什么呢,我们可以类比一下,我们传的实际上是头指针plist的值NULL,而不是plist的地址,所以我们不能通过形参改变实参头指针plist的值,我们只能改变形参pphead头指针的值,而不能修改plist指针本身的值,也就是不能改变plist指针所指向的内存块。
我们可以发现plist所指向的内存块仍然是NULL。
用二级指针则会存放plist指针的地址,二级指针解引用*pphead和plist其实是同一块内存,同时也指向了同一块内存区,即改变*pphead的内容同时也改变了plist的内容。
那么我们应该什么时候用二级指针呢?即我们想要改变头指针所指向的节点时,如空链表中插入一个节点,删除头结点,销毁链表。那么尾插实链表,尾删多节点链表,特定位置插入删除,遍历使用一级指针即可,当然也可以用二级指针。当然如果我们需要用到头指针使,我们最好使用二级指针。
因为尾插的特殊情况就是在空链表中插入一个头节点,所以我们要将原来头指针的NULL值改为头节点的地址,如果是实链表尾插,我们不需要改变头指针所指向的内容,也就可以不用二级指针。这一种情况大家也可以去试试,一级指针是可以使用的。
3、头插
有了上面的解惑,我们就能更好地写出头插的代码,因为我们要将头指针的值变成我们新插入的头节点,所以我们传实参头指针plist的地址,才能通过*pphead改变plist的值。使我们的头指针plist指向新的头节点。
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
//分析:新的头节点的next要指向原头节点
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);//申请一个新的节点
newnode->next = *pphead;//新节点的next指向原头结点
*pphead = newnode;//头结点的地址变成新插入的节点,这样我们就改变了头指针plist的值
}
4、尾删与头删
尾删操作,有了上面的思路,我们有两种情况,即只有头节点的链表和两个节点以上的链表。那么删除头结点,又与我们说的二级指针相关了,我们要改变头指针的值为空。而另一种情况不改变头指针的值,一级指针也是可行的,但是我们写的函数要包含这两种情况,所以我们选择用二级指针传递头指针的地址。
//尾删
void SLTPopBack(SLTNode** pphead)
{
assert(pphead&& *pphead);//不能为空指针和空链表
//1.链表只有一个节点
//2.链表有两个以上节点
if ((*pphead)->next == NULL)//只有一个节点。加(),因为->的优先级要高于*
{
free(*pphead);
*pphead = NULL;
}
else //两个元素
{
SLTNode* tail = *pphead;
SLTNode* prev = NULL;
//不能直接释放尾节点,因为倒数第二个节点的next指针里面存放了尾节点的地址,若直接释放尾节点,倒数第二个节点的next指针则会变成野指针。
//所以我们还需要找到我们要找到倒数第二个节点,得到倒数第二个节点*prev的next指针。
//所以我们要去找尾节点与prev节点
while(tail->next!=NULL)//找尾,当走到倒数第二个节点时,进入最后一次循环
{
prev = tail; //prev和tail现在都是倒数第二个节点
tail = tail->next;//再让tail走到尾节点,结束查找。这样就能将prev和尾节点都找到
}
free(tail);
prev->next = NULL;//找到后释放尾节点,将prev节点的next指针置空,尾节点的指针也置空
tail = NULL;
}
}
当然尾删时还有一些细节和方法要注意,那就是我们直接删除尾节点,释放尾节点的空间时,倒数第二个节点的next指针并没有置为空,这个时候释放掉尾节点next指针就会变成野指针,会错误的访问已经被释放的空间,所以我们应该先将倒数第二个节点变成新尾节点,next指针置为空,再释放原尾节点。那么我们就要找到倒数第二个节点,我们的思路就是快慢指针,快慢指针通常就是用于寻找倒数第K个节点,之后我们还会用到。 我们将找尾tail指针指向头节点,找前prev指针先赋空值,然后进入循环遍历,先让prev指针往后走一步到tail指针的位置,再让tail指针走到下一个节点,当tail指针指向节点的next指针为空时,即找到了尾节点,由于prev指针比tail指针慢一步,所以prev指针也指向了倒数第二个节点。
下面我们来看头删,头删就很简单了,同样要注意,因为我们要将头节点的next指针存储的地址即第二个节点的地址存储起来传给头指针,所以我们不能直接释放头指节点,否则我们找不到第二个节点。
void SLTPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
//不能直接删掉头结点,然后使*pphead指向下一个节点的地址。
// 释放头节点后无法找到第二个节点
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
5、特定位置后插与前插
首先我们来看特定位置后插,这种情况比前插的要简单。插入节点,就是要改变其他节点与插入节点的指向。后插只需要将pos->next的值传给新节点,再将pos->next指向新节点,因为这里不涉及到头指针头结点,所以不需要担心二级指针的问题。
void SLTInsertBack(SLTNode* pos, SLTDataType x)//不需要遍历链表找prev,知道pos和pos->next即可
{
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
而前插则不一样,因为我们需要找到pos节点的前一个节点,所以我们需要prev指针找到前一个节点,标志为prev->next==pos。从头节点开始遍历寻找。找到后prev->next指向新节点,新节点newnode->next指向pos即可。当然有一种情况就是头插,这种情况我们可以直接调用头插函数。
void SLTInsertFront(SLTNode** pphead, SLTNode* pos,SLTDataType x)
{
assert(pphead && *pphead);
assert(pos);
//创建新的节点
SLTNode* newnode = SLTBuyNode(x);
//要使POS前一个节点指向pos,pos指向后一个节点。所以要遍历链表找到POS前一个节点
SLTNode* prev = *pphead;
//当pos == *pphead, 即头插时
if (pos == *pphead)
{
SLTPushFront(pphead, x);
}
while (prev->next != pos)//通过判断Next指针是否指向Pos来找出prev节点。
{
prev == prev->next;
}
newnode->next = pos;
prev->next = newnode;
}
6、删除特定节点和后删特定节点
删除特定节点的操作,我们要注意头删的情况,而其他的情况,我们正常操作即可,比如我们要找到前一个节点,要先保存下一个节点的地址等。
void SLTErase(SLTNode** pphead, SLTNode* pos)//删除特定节点,注意头删情况
{
assert(pphead && *pphead && pos);
//pos是头结点的情况以及不是
if (pos == *pphead)
{
//头删
SLTPopFront(*pphead);
}
else
{
//要找到pos前一个节点
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
特定位置后删除也很简单,我们只要主义这个特定位置不能是尾节点,即pos->next不为NULL就行了。
void SLTEraseBack(SLTNode** pphead, SLTNode* pos)
{
assert(pos && pos->next);//如果指定位置为尾节点,则不能删除下一个节点
SLTNode* del = pos->next;//del为Pos后一个节点
pos->next = del->next;
free(del);
del = NULL;
}
7、查找
我们经常需要查找某个节点的数据,只要遍历链表,将节点中的数据与所需数据核对即可。返回我们所需要的节点。
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)//返回一个节点
{
SLTNode* pcur = phead;//用临时指针操作
while (phead)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;//遍历
}
return NULL;
//phead == NULL,或者遍历链表没有找到,到尾节点跳出循环
}
8、销毁
既然我们的节点都是通过动态内存申请的空间,就一定要记得释放手动申请的堆区空间。
我们将节点都置为空,结构体指针也赋为为NULL。同样的,置空时要注意能否找到下一个节点的地址,所以我们要删除一个节点时,要将下一个节点的地址用一个指针存储起来,即该节点的next指针的值要存起来,我们才能遍历链表,全部置空。
void SLTDestory(SLTNode** pphead)//销毁
{
assert(pphead && *pphead);//链表本身也不能为空
SLTNode* pcur = *pphead;//用临时指针进行代替头结点进行操作
while (pcur)
{
SLTNode* Next = pcur->next;//要将下一个节点的地址存储起来
free(pcur);//释放当前节点
pcur = Next;//指向下一个节点
}
*pphead = NULL;//最后头指针也置为空
}
以上就是单链表的基本内容,大家可以刷一刷力扣上经典的单链表算法题来运用知识点,希望这篇文章对你学习单链表有帮助,感谢您的浏览与支持!如有不足或者错误请指出,谢谢!