前几天我们学习了顺序表,但是顺序表也有很多缺点,比如:中间和头部的插入删除,时间复杂度是O(N)、容易造成内存浪费,重复申请空间需要耗费时间。所以今天我们就来学习线性表中的另外一个结构——链表。
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
实际中,链表有很多种不同的结构,如:1.带头或不带头、2.单向或双向、3.循环或非循环。这些情况组合起来就有8种不同的结构。
虽然有这么多结构,但是我们最常见的有2种:
1.无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。
2.带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。
我们下面就告诉大家单链表和带头双向循环链表是如何实现的。
一个数据域和一个指向下一个结点的指针域。
typedef int SLTDateType;
typedef struct SListNode
{
SLTDateType date;
struct SListNode* next;
}SLTNode;
每次创建一个新结点就申请一块空间,并且将数据初始化,指针指向NULL。
SLTNode* CreatSListNode(SLTDateType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
assert(newnode);
newnode->date = x;
newnode->next = NULL;
return newnode;
}
遍历链表,当头节点的指针为NULL时,链表遍历结束,停止打印。
void SListPrint(SLTNode* phead)
{
while (phead)
{
printf("%d ", phead->date);
phead = phead->next;
}
printf("NULL\n");
}
插入时要注意,如果头指针为NULL,说明此时要插入的应该是链表的第一个结点,所以应该将头指针指向插入的结点,否则就遍历找到链表的最后一个结点,然后将新结点链接到后面即可。
插入:
void SListPushBack(SLTNode** pphead, SLTDateType x)
{
assert(pphead);
SLTNode* newnode = CreatSListNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
删除时,先检查头结点的下一个结点是否为NULL,如果是,说明此时链表只有一个结点,直接删除即可,如果不是,就遍历链表,找到链表最后一个结点和最后一个结点的前一个,把最后一个结点删除并将前一个结点作为新的尾结点,next指针指向NULL。
删除:
void SListPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* tail = *pphead;
SLTNode* tailprev = NULL;
while (tail->next != NULL)
{
tailprev = tail;
tail = tail->next;
}
free(tail);
tailprev->next = NULL;
}
}
头部插入比较简单,直接将新结点的next指针指向现在的头结点,然后将新结点作为新的头结点即可。
插入:
void SListPushFront(SLTNode** pphead, SLTDateType x)
{
assert(pphead);
SLTNode* newnode = CreatSListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
头部删除也比较简单,将现在头结点的下一个结点作为新的头结点,然后删除现在的头结点即可。
删除:
void SListPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* cur = (*pphead)->next;
free(*pphead);
(*pphead) = cur;
}
查找结点就是直接遍历链表,如果某个结点的数据与要查找的数据相等,就返回这个结点,如果一直找不到,返回NULL。
将查找的返回类型设置成这样是为了后边方便与确定位置的插入和删除配和使用。
SLTNode* SListFind(SLTNode* phead, SLTDateType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->date == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
插入时要注意如果插入的位置是链表中第一个结点的话,就是头插的意思,这里可以直接调用头插函数。如果不是第一个结点,就遍历链表,记录该位置的前一个结点的位置,然后插入到前一个结点的后面即可。
插入:
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDateType x)
{
assert(pphead);
assert(pos);
if (pos == *pphead)
{
SListPushFront(pphead, x);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
SLTNode* newnode = CreatSListNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
删除时也要注意,如果删除的位置是链表中第一个结点的话,直接调用头删函数。如果不是,就继续遍历链表,记录该结点的前一个结点的位置,然后将让前一个结点的next指针指向该结点的下一个结点,最后删除该结点即可。
删除:
void SListErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(pos);
if (pos == *pphead)
{
SListPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
单链表的主要功能介绍完了,但单链表也存在一些缺点,比如:找尾结点需要遍历链表,比较耗时间,找某个结点的前一个结点也需要遍历。下面我们来学习带头双向循环链表,带头双向循环链表可以很好的解决这些问题,使用起来非常方便。
一个数据域,一个指向下一个结点的后继指针,一个指向前一个结点的前驱指针。
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}LTNode;
每次创建结点就申请一块空间,新结点的前驱指针和后继指针都为NULL。
LTNode* CreatListNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc");
exit(-1);
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
带头双向循环链表的初始化就是创建一个头结点,头结点不存储任何数据,而且初始头结点的前驱指针和后继指针都是指向自己的,因为此时链表中没有其他结点。
LTNode* ListInit()
{
LTNode* phead = CreatListNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
打印时注意要从头结点的下一个位置开始遍历打印,当遍历到头结点时遍历结束。
void ListPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
这时带头双向循环链表的优势就体现出来了,不需要遍历找尾结点,因为头结点的前驱指针必然指向尾结点,比单链表方便很多,但插入时要注意指针的顺序。最后把头结点的前驱指针指向新的尾结点即可。
插入:
void ListPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = CreatListNode(x);
LTNode* tail = phead->prev;
tail->next = newnode;
newnode->prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
删除时要确保链表中除了头结点还有其他结点,即确保头结点的后继指针指向的不是头结点本身,然后直接找到尾结点和尾结点前一个结点,把尾结点的前一个结点作为新的尾结点,最后删除之前的尾结点即可。
删除:
void ListPopBack(LTNode* phead)
{
assert(phead);
assert(phead->next != phead);
LTNode* tail = phead->prev;
LTNode* tailprev = tail->prev;
tailprev->next = phead;
phead->prev = tailprev;
free(tail);
}
插入时要注意修改指针时的顺序,确保头结点的后继指针是最后修改的,否则就会找不到当前头结点的下一个结点而导致出错。
插入:
void ListPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = CreatListNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
newnode->prev = phead;
}
删除时要确保链表中除了头结点还有其他结点,先记录头结点的下一个结点,然后把头结点的next指针指向头结点下一个结点的下一个结点,头结点下一个结点的prev指针指向头结点,最后删除之前记录的头结点的下一个结点即可,这就是头部删除。
删除:
void ListPopFront(LTNode* phead)
{
assert(phead);
assert(phead->next != phead);
LTNode* pheadnext = phead->next;
phead->next = pheadnext->next;
pheadnext->next->prev = phead;
free(pheadnext);
}
查找时也要保证链表中除了头结点还有其他结点,遍历链表找到该结点,最后返回该结点的指针,这样可以配和下面要介绍的确定位置的插入删除使用。
LTNode* ListFind(LTNode* phead, LTDataType x)
{
assert(phead);
assert(phead->next != phead);
LTNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
插入时也和前面的函数一样,注意指针修改的顺序即可。
插入:
void ListInsert(LTNode* phead, LTNode* pos, LTDataType x)
{
assert(phead);
assert(pos);
LTNode* newnode = CreatListNode(x);
pos->prev->next = newnode;
newnode->prev = pos->prev;
newnode->next = pos;
pos->prev = newnode;
}
删除时也要注意指针修改的顺序。
删除:
void ListErase(LTNode* phead, LTNode* pos)
{
assert(phead);
assert(pos);
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
销毁时,先确保链表中除了头结点还有其他结点,然后调用删除函数,遍历链表并依次删除,最后删除头结点即可。
void ListDestory(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
ListErase(phead, cur);
cur = next;
}
free(phead);
}
链表并不是万能的,也有缺点,链表实际上是和顺序表相辅相成的,二者都有自己的优点和缺点,使用时要灵活使用。
不同点 | 顺序表 | 链表 |
---|---|---|
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
随机访问 | 支持O(1) | 不支持O(N) |
任意位置插入或者删除元素 | 可能需要移动元素,效率低O(N) | 只需修改指针 |
插入 | 动态顺序表,空间不够时需要扩容 | 没有容量的概念 |
适用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |
到这里,顺序表和我们常见的俩种链表(单链表和带头双向循环链表)就全部介绍完了,我们可以发现,这些结构的基本操作代码实现起来并不复杂,主要是要保证思路清晰,并考虑特殊情况就能完成,而且不管那种数据结构都有它存在的意义,没有完美的数据结构,每种结构都有自己的优点和缺点,我们使用的时候要根据使用场景来确定最合适的结构。
接下来我会继续介绍更多的数据结构,如:栈和队列,二叉树等,大家可以持续关注。
最后,感谢各位大佬的耐心阅读和支持,觉得本篇文章写的不错的朋友可以三连关注支持一波,如果有什么问题或者本文有错误的地方大家可以私信我,也可以在评论区留言讨论,再次感谢各位。