前言
顺序表是存在一些固有的缺陷的:
- 中间/头部的插入删除,时间复杂度为O(N),效率比较低(因为需要挪动数据)
- 增容(需要开辟新空间释放旧空间)需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
- 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
其中链表就可以很好的解决上面的问题。
链表
链表的概念及结构
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
数据结构中:
注:
- 链表在逻辑上是线性,物理上不一定是线性的
- phead一般是指向第一个节点的指针(头指针)
链表的分类
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
- 单向或者双向
2. 带头或者不带头
注:
- d1-头节点,head-哨兵位的头节点,这个节点不存储有效数据
- 带哨兵位头节点的好处是如果头指针指向的是不带哨兵位的头节点时再做头插尾插等操作就得传二级指针,因为头插尾插等操作只要动到头节点就需要改变头指针。如果是带哨兵位的头节点就不用传二级指针,因为头指针永远都不会变一直指向带哨兵位的头节点,头插是在带哨兵位的头节点之后插入,尾插也一样。永远不会动到头指针因为带哨兵位的头节点没有存储有效数据。
- 这里的带头指的是哨兵位的头节点,不存储有效数据
- 循环或者非循环
虽然有这么多的链表的结构,但是实际中最常用还是两种结构:无头单项非循环链表、带头双向循环链表
- 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
- 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了。
无头单向非循环链表的相关实现
链表的定义
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data; // int val;
struct SListNode* next; //注意这个地方不能使用SLTNode* next;这个地方还没有typedef出来就开始使用SLTNode,编译器找定义时只会向上找,然而上面并没有SLTNode。
}SLTNode;
链表的打印
链表的打印需要将头节点的地址进行传参,从指向头节点位置的指针开始依次循环遍历直到遇到NULL为止停止打印节点的数据
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
链表的尾插
链表的尾插首先通过循环的方式找到链表中的最后一个节点,接着要创建一个新节点,节点里面存放尾插的数据,最后将新节点的地址存放到最后一个节点中从而完成连接的过程。这时是最常见的一种情况,还有一种是当链表为空时(没有一个节点,头指针存放NULL),需要创建一个新节点并把新节点的地址存放到头指针中即可。
SLTNode* BuySListNode(SLTDataType x)
{
SLTNode* node = (SLTNode*)malloc(sizeof(SLTNode));
if (node == NULL)
{
printf("malloc fail\n");
exit(-1);
}
node->data = x;
node->next = NULL;
return node;
}
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);//头指针可能为空,但是头指针的地址是不可能为空的
if (*pphead == NULL)
{
SLTNode* newnode = BuySListNode(x);
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
SLTNode* newnode = BuySListNode(x);
tail->next = newnode;
}
}
注意:
- 这里不要使用一级指针进行传参,因为指针传值,相当于把plist指针变量得值拷贝给phead,phead里面赋值newnode,而phead的改变不会影响plist。因此想在函数内部修改外面plist的值就得使用二级指针进行传参。
- 要改变传过来的指向第一个节点的指针就传二级;不改变传过来的指向第一个节点的指针就传一级
链表的头插
链表的头插只需要创建一个新节点,将头指针内存放的第一个节点的地址存储到新结点中,再将新节点的地址存放到头指针中(其中这两步顺序不能颠倒),从而完成头插的连接。注意当链表为空时这个头插不会出现问题。
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
链表的尾删
链表尾删的方式有两种:
- 一种是定义两个指针(prev、tail),当tail指向下一个节点之前先将tail赋给prev,然后tail在指向下一个节点。这样以此往复直到tail指向最后一个节点停止,此时prev指向tail所指向节点的前一个节点。最后将tail所指向的节点释放掉,再将prev所指向节点的next置成NULL。
- 另一种是定义一个指针(tail)利用单链表的特点完成链表的尾删,单链表的特点是只能向一个方向走,只要走完就回不去了。因此tail指针找得到下一个节点但是找不到当前节点的前一个节点。 那直接找前一个从而也就能找到它的下一个,单链表是在当前位置找不到前一个但是在前一个位置是可以找到后一个的。此时就需要看tail所指向当前节点的下一个节点的下一个节点是否为NULL,如此往复直到它为NULL停止就说明tail所指向当前节点的下一个节点就是单链表的最后一个节点,那tail所指向当前节点就是最后一个节点的前一个节点。最后将tail所指向当前节点的下一个节点释放掉,并将tail所指向当前节点的next置成NULL即可。
注:
- 当链表尾删删除的是最后一个节点时需要注意头指针的指向问题(eg:phead)以及节点指针的野指针问题(eg:prev、tail->next)
- 链表尾删时需要分三种情况:无节点、一个节点、多个节点。当链表为空时进行尾删此时需要对头指针进行判断(eg:assert、if)。当链表有一个节点时(即头节点的next存储的是NULL)直接对该节点进行释放并把头指针指向空。当链表有多个节点时按照上述方式处理即可。
//第一种:
void SListPopBack(SLTNode** pphead)
{
// 检查参数是否传错
assert(pphead);
// 没有节点断言报错 -- 处理比较激进
// 无节点
assert(*pphead);
// 一个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
// 多个节点
else
{
SLTNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail