本篇博客会介绍一下链表,然后来实现单链表的功能(增删查改)。篇幅较长,代码部分较多,建议按目录查看。
目录
一、链表的概念与结构
1.1 链表的概念
概念:链表是一种物理存储结构上非连续、非连续的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
1.2 链表结构的存储方式
链表存储结构可以用逻辑结构和物理结构来表示,这里我们看看这两种方式下链表的形式。
逻辑结构:
物理结构:
注意:
1.从上图可看出,链式结构在逻辑上是连续的,但是在物理上不一定连续,他们这种连续的结构主要是其指针域指向了下一个结点的地址。
2.现实中的结点一般都是从堆上申请出来的。
3.从堆上申请空间,是按照一定的策略来分配的,两次申请的空间可以能连续,也可能不连续。
二、链表的分类
实际中链表的结构非常多样,一下情况组合起来共有8种情况。
分别为单向/双向、带头/不带头、循环/不循环这8种情况。
1.单向or双向
2. 不带头or带头
3. 循环or不循环
虽然有这么多的链表结构,但是我们实际中常用的还是两种结构:
原因:
1.无头单向非循环链表:结构简单,一般不会单独用来存放数据。实际中更多是作为其他数据结构的子结构。如:哈希桶、图的领接表等等。另外这种结构在笔试面试中出现很多。
2.带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用链表的数据结构,都是带头双向循环链表。另外这个结构虽然复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,这个结构后续我们会用代码来实现。
三、单链表的实现
1. 定义单链表的结构
对于每个链表结点,除了存放元素自身的信息外,还需要存放一个指向其后继的指针。
typedef struct SListNode
{
SLTDateType date; //用于存放数据
struct SListNode* next; //指向下一个结点的指针
}SLTNode;
这里的SLTDateType是链表中数据域存放的数据类型。因为我们使用单链表时,可能会存放整形数据、字符型数据、浮点型数据,所以我们使用SLTDateType来表示链表中存放的数据类型。如下:
//存放的数据类型
typedef int SLTDateType;
2. 新结点的创建
现在我们开始创建链表的结点,我们的思路是:调用这个函数时,我们malloc出一块空间,然后将数据放入数据域中,并将这个结点的下一个元素指向NULL,最后将开辟的这个节点的地址返回。
每条代码的意思我打上注释,具体实现大家可以看看。
//创建新节点
SLTNode* BuyListNode(SLTDateType x)
{
//开辟一块空间,然NewNode的指向这块空间;
SLTNode* NewNode = (SLTNode*)malloc(sizeof(SLTNode));
//判断空间开辟是否成功
assert(NewNode);
//将指针域置为空,即该节点指向的下一个节点为空
NewNode->next = NULL;
//放入数据
NewNode->date = x;
//返回该空间的地址
return NewNode;
}
3. 链表的尾插
现在我们创建了节点,但是节点的指向为空,我们无法做到将各节点间串起来,所以我们要再创建一个尾插接口来实现这个功能。
思路:
1.复用BuyListNode创建一个新节点。
2.判断该链表是不是空链表,如果是,则让新开辟的节点为第一个节点。
3.如果不是,则用一个指针找到尾节点,让尾节点的指针域指向新开辟的节点。
void SListPushBack(SLTNode** pphead, SLTDateType x)
{
//1.创建一个新节点
SLTNode* NewNode = BuyListNode(x);
//2.如果phead是空,则表示一个结点都没有
//那就要将开辟的链表当作第一个节点
if (*pphead == NULL)
{
*pphead = NewNode;
return;
}
//3.找尾
SLTNode* Tail = *pphead;
while (Tail->next != NULL)
{
Tail = Tail->next;
}
//此时Tail指向最后一个结点
//则将新开辟的结点赋予Tail
Tail->next = NewNode;
//函数结束之后,NewNode和Tail会被销毁,但是开辟的空间依然在。
}
注意:
这里函数参数我们传入的是二级指针,因为我们这里做了一个行为,即如果phead是空,我们就将新节点作为第一个节点。这里我们改变了外面第一个节点的指向,所以我们要使用二级指针。因为函数形参是实参的临时拷贝,所以如果我们要改变一个指针的指向,要传入它的地址,指针的地址,所以我们要传入二级指针。
4. 链表的头插
尾插实现完那我们来实现头插的。思路如下:
//链表的头插
void SListPushFront(SLTNode** pphead, SLTDateType x)
{
//创建一个新节点
SLTNode* NewNode = BuyListNode( x);
//新节点指向第一个节点
NewNode->next = *pphead;
//改变了外面的实参,将NewNode改为第一个节点
*pphead = NewNode;
}
注意:
同样,我们改变了外面指针的指向,将新结点变成了第一个结点。所以我们使用二级指针。
5. 链表的打印
实现了头插、尾插,我们再来实现一个打印函数来检验一下成果。这个函数的话,我们不用改变实参的值,所以传入一级指针即可。
//链表的打印
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
//直到cur == NULL
while (cur != NULL)
{
printf("%d->", cur->date);
cur = cur->next;
}
printf("NULL\n");
}
6. 链表的头删
这个功能因为要改变实参的指向,所以要传入二级指针。思路如下:
//头删的实现
void SListPopFront(SLTNode** pphead)
{
//检查plist是否为空
assert(*pphead);
//先保存plist的指向,即为plist的下一个结点的地址
SLTNode* next = (*pphead)->next;
//释放plist节点
free(*pphead);
//next置为第一个结点
*pphead = next;
}
7. 链表的尾删
思路:
1. 判断传入的链表是否为空,为空则直接退出。
2. 如果传入的链表只有一个节点,那直接free掉这一个节点,返回NULL即可。表示链表已删空。
3.如果传入链表有2个或两个以上的节点。那遍历找到最后一个节点,然后free该节点,然后将最后一个节点的前一个结点的指针域指向置为空。
注意:
这里传入的是二级指针,我们将如果有一个结点的情况下,将实参变为了空。
//尾删的实现
void SListPopBack(SLTNode** pphead)
{
//去除空结点的请况
assert(*pphead);
//只有一个节点的情况
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
//多个结点的情况
//方法1:使用两个节点
SLTNode* cur = *pphead;
SLTNode* prev = NULL;
//找到最后一个节点
while (cur->next!= NULL)
{
prev = cur;
cur = cur->next;
}
free(cur);
cur->next = NULL;
}
这里还可以使用方法二:
因为在上面已经处理了,结点数为0和结点数为1的情况,所以找尾的这个遍历我们使用一个指针cur就可以了,其实思路相差不大。
//方法2:使用一个节点,判断Temp1->next->next!=NULL 让Temp1找倒数第二个 SLTNode* cur2 = *pphead; while (cur2->next->next != NULL) { cur2 = cur2->next; } free(cur2->next); cur2->next = NULL;
8. 链表的头删
思路:
1. 检查链表是否为空
2. 保存下一个结点的地址
3. 释放第一个结点
4. 将下一个结点置为第一个结点。
//头删的实现
void SListPopFront(SLTNode** pphead)
{
//检查plist是否为空
assert(*pphead);
//先保存plist的指向,即为plist的下一个结点的地址
SLTNode* next = (*pphead)->next;
//释放plist节点
free(*pphead);
//将next的地址给plist,则是将下一个节点链接起来
*pphead = next;
}
功能检测:
还是使用上面的测试案例,我们头删、尾删各5次,结果应该是剩下两个1;
9.链表的查与改
查和改通常是一起使用的,我们传入想要的值,找到那个值返回这个结点的地址,然后将新值放入该结点中。这个两个函数比较简单,就不多赘述了。
//查找的实现
SLTNode* SListFind(SLTNode* phead, SLTDateType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->date == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
//修改的实现
void SListModify(SLTNode* phead, SLTDateType Data, SLTDateType NewData)
{
assert(phead);
//使用一个结点接受返回结点的地址
SLTNode*ModifyNode= SListFind(phead, Data);
assert(ModifyNode);
ModifyNode->date = NewData;
}
查改数据有很多种方式,因为都比较简单,只需要遍历的问题而已,就不多介绍了,如果有兴趣的话可以将多种查改数据的方式给实现出来。
10. 在pos位置之前插入
实现了上面的头插、尾插,接下来我们实现一个传入pos位,那我们就在pos位之前插入一个结点。
//在pos位置之前插入
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDateType x)
{
//pos不能为空
assert(pos&&pphead);
//头插
if (pos == *pphead)
{
SListPushFront(pphead, x);
}
SLTNode* prev = *pphead;
//找到pos之前的位置
while (prev->next != pos)
{
prev = prev->next;
}
//创建一个新节点
SLTNode* newnode = BuyListNode(x);
//将prev指向新节点
prev->next = newnode;
//新结点放在pos之前
newnode->next=pos;
}
11. 删除pos位置
实现删除pos位置的值,不局限于头删和尾删。
//删除pos位置
void SListErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && pos);
//如果删除的结点是链表的第一个结点
if (*pphead == pos)
{
SListPopFront(pphead);
}
SLTNode* prev = *pphead;
//找到pos位置之前的一个位置
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
12. 在pos位置之后插入
实现这个函数,我们可以在任意pos位置之后插入数据。
在pos位置之后插入数据可以做到不用遍历链表,时间复杂度低。
思路:
1.创建一个新结点
2.将该结点指向pos的下一个结点。
3.然后将pos指向新结点。
//在pos位置之后插入x
void SListInsertAfter(SLTNode* pos, SLTDateType x)
{
assert(pos);
SLTNode* NewNode = BuyListNode(x);
//顺序不能颠倒!!!
NewNode->next = pos->next;
pos->next = NewNode;
}
注意:
这里我没有传入二级指针,因为我们没有将实参进行修改。
13. 删除pos位置之后的结点
思路:
1.判断传入的pos是否为空
2.判断pos的下一个位置是否为空
3.保存pos后的下一个结点,将pos指向下一个结点所指向的结点。
4.释放待删除的结点。
//删除pos位置之后的结点
void SListEraseAfter(SLTNode* pos)
{
//判断pos是否为空
assert(pos);
//判断pos的下一个位置是否为空
if (pos->next == NULL)
{
return;
}
//将pos下一个结点保存起来
SLTNode* del = pos->next;
//使pos指向del的下一个结点。
pos->next = del->next;
free(del);
}
14. 链表的销毁
//链表的销毁
void SListDestoy(SLTNode* phead)
{
SLTNode* cur = phead;
SLTNode* del = NULL;
while (cur)
{
del = cur;
cur = cur->next;
del->next = NULL;
free(del);
}
}
结语
本篇的介绍到此就结束了,下篇博客会实现带头双向循环链表,这种一种常见的链表结构,名字听着吓人,但是功能实现比单链表简单很多。
如果感觉还的不错的话可以关注一下留个赞呗,你们的鼓励是我最大的动力。我们下期再见。