目录
学习了顺序表就知道,顺序表在大量的头插或者中间插入的时候效率是很低的,需要频繁挪动数据,并且顺序表的扩容消耗也是不小的,那么有没有一种数据结构在处理大量头插和中间插入的时候效率非常高呢?是有的,今天我们就来学习一下链表。
一、链表是什么
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现。
链表它是由一个一个节点连接起来的。每一个节点里面有两个值,data存放的是数据,next存放的是 下一个节点的地址。那么如果我们要用C语言表示它应该怎么表示呢?首先C语言内置的类型是无法表示的,因此只能自定义一个结构体类型,结构体里面有两个变量,data,和next。
typedef int DataType;
typedef struct ListNode
{
DataType val;
struct ListNode* next;
}ListNode;
这个地方我们最后typedef一下类型,这样一来如果我们需要改变链表存放的数据类直接更改typedef就可以,结构体typedef一下是因为它太长了。
二、链表的分类
带头不带头
循环不循环
单向双向
一共可用组合成八种,但是最长用的就两种:
1.无头单向非循环链表:**结构简单**,一般不会单独用来存数据。实际中更多是作为**其他数据结构的子结构**,如哈希桶、图的邻接表等等。另外这种结构在**笔试面试**中出现很多。
2. 带头双向循环链表:**结构最复杂**,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
本文实现的是无头单向非循环的链表。
三、链表的打印
我们怎么打印一个链表的值呢?要遍历对吧,那么现在问题来了。
1.如何走向下一个节点?
我有链表的头节点pList,可是我如何到下一个节点呢?我们不要忘了,pList里面有一个指针,存放的是下一个节点的地址,因此我们直接让pList指向pList的next就好了,然后一直进行一个循环就可以继续往后走
2.循环的结束条件是什么?
链表的最后一个节点的下一个节点是NULL指针,因此我们只需要判定当前遍历的节点是否为空即可。注意,这个地方的判定条件不可以是cur->next != NULL,这样的话最后一个节点是打印不到的。
3.链表遍历代码实现
//单链表打印
void PrintList(ListNode* head)
{
ListNode* cur = head;
while (cur != NULL)
{
printf("%d->", cur->val);
cur = cur->next;
}
printf("NULL\n");
}
四、链表的尾插
1.尾插的逻辑及函数参数为什么要传二级指针?
我们先来设计一下链表的函数参数应该传什么,要尾插肯定你要给我一个数据吧,我要尾插的值是什么。其次,我要尾插,那我要尾插到哪一个链表呢?是不是还要传一个链表的头节点给我。因此要有两个参数,一个是链表的头节点,一个是要插入的数据。
接下来我应该如何尾插呢?首先我们肯定是要申请一个新节点的。
情况1:头节点为空
如果头节点是空,直接把新的节点给给头结点即可。
情况2:头节点不为空
头节点不为空,说明该链表有一个及以上的节点个数,此时我们要先找到链表的尾节点,然后让尾节点的next指向新的节点即可。
由于申请节点是后面头插啥都要用到的,因此我们先把申请节点写成一个函数。
1.申请节点函数
//申请一个节点
ListNode* BuyListNode(DataType x)
{
ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
if (newNode == NULL) {
perror("malloc fail!");
return newNode;
}
newNode->val = x;
newNode->next = NULL;
return newNode;
}
2.小小测试一下尾插
上面的尾插代码其实就是我们上面所说的逻辑,但是为什么打印的时候结果是NULL呢?是我们尾插逻辑错了吗?其实不是的,问题出在ListNode* plist这个参数上。
要理解上面这个问题,我们先来看下面几段代码
我们调用了fun函数,函数参数传的是a,此时x的改变会不会影响a?显然是不会的,因为,x是a的拷贝,x的改变不会影响a,那我们如何能让x的改变影响a呢?把a的地址传过去就可以了,这个想必大家都没有问题。
再来看下面这段代码。
我想问,现在ptr的改变会不会改变p?是不会的,其实这个地方也是一个拷贝,只不过这次拷贝的是一个地址而已,我们画一下栈帧就可以很好理解这个问题,什么是栈帧呢?就是每调用一个函数都会开辟一个栈帧空间,函数里面的变量都是在这个栈帧空间里的。
ptr的改变是不会影响p的,如果要改变int*,必须要用二级指针int**。
此时的函数栈帧是这样的。此时我传递的是p的地址,pptr解引用之后就是指向p的。
现在我们再来看链表的头插这个地方。
这个地方其实就和上面的情况是一样的,要改变ListNode*,我要传ListNode**过去,这里可以这样理解,head和ptr里面存放的值是一样的,都指向同一个地址,但是head和ptr是两块空间,你把ptr空间里面的存放的东西改变了,与我head何干?并且还会有内存泄漏,ListPushBack执行完后,直接就把栈帧销毁了,newnode指向的那块空间直接找不到了
然而是二级指针就不一样了。此时的栈帧是这样的。
这个地方传递的是 head的地址,对pplist解引用后就指向了head。这个地方的二级指针就是在第一次为NULL的时候使用的,其它时候用一级指针就可以。
2.尾插的代码
void ListPushBack(ListNode** pplist, DataType x)
{
assert(pplist);
ListNode* newnode = BuyListNode(x);
if (*pplist == NULL)
{
*pplist = newnode;
}
else
{
//找到链表的尾节点
ListNode* tail = *pplist;
while (tail->next)
{
tail = tail->next;
}
tail->next = newnode;
}
}
五、链表的尾删
情况1:链表为空
链表要是空你还玩啥玩,直接返回
情况2:链表有一个节点
如果只有一个节点,直接free掉,然后指向NULL就行。
情况3:链表有两个及以上节点。
如果有两个及以上节点,我们是不是要先找到尾?然后在把尾删除掉。这样就完了吗?这样的话尾的前一个节点的next是不是指向的就是一个野指针呀,因此找尾的同时要找到尾的前一个节点,把前一个节点的next置为NULL。
情况1和情况和是可用同时处理的,因为对一个空节点free是不会有任何影响的。
//单链表尾删
void ListPopBack(ListNode** pplist)
{
ListNode* prev = NULL;
ListNode* tail = *pplist;
//1.空和只有一个节点
if (tail == NULL || tail->next == NULL)
{
free(tail);
*pplist = NULL;
}
//2.两个以上节点
else
{
while (tail->next)
{
prev = tail;
tail = tail->next;
}
free(tail);
tail = NULL;
prev->next = NULL;
}
}
六、链表的头插
第一步肯定要先申请一个新的节点。
情况1:链表为空
链表如果为空,直接插入。
情况2:链表不为空
链表不为空直接让,新节点的下一个指向头节点即可,然后把头结点更新成newnode
//单链表头插
void ListPushFront(ListNode** pplist, DataType x)
{
assert(pplist);
ListNode* newnode = BuyListNode(x);
if (*pplist == NULL)
{
*pplist = BuyListNode;
}
else
{
newnode->next = *pplist;
*pplist = newnode;
}
}
七、链表的头删
情况1:链表头为NULL
为空还玩啥呢直接返回了
情况2:链表有一个节点
直接删除
情况3:链表有二个及以上节点
此时要先保存头结点的下一个节点,然后把头节点删除,更新头结点。
//单链表头删
void ListPopFront(ListNode** pplist)
{
ListNode* first = *pplist;
//1.空
//2.1个节点
//3.两个及以上
if (first == NULL)
{
return;
}
else if (first->next == NULL)
{
free(first);
*pplist = NULL;
}
else
{
ListNode* next = first->next;
free(first);
first = NULL;
*pplist = next;
}
}
八、链表的查找
查找,直接遍历就行。
ListNode* ListFind(ListNode* plist, DataType x)
{
ListNode* cur = plist;
while (cur)
{
if (cur->val == x) return cur;
cur = cur->next;
}
return NULL;
}
九、链表在pos位置之后插入
先在这里问一个问题,为什么要在pos位置之后插入,在之前插入不行吗?可以是可以,但是有点麻烦,你画图试试就知道了。
插入第一步,先申请一个新的节点。
我们先来看pos可能是空吗?如果你是空那你还玩啥呢?因此直接断言一下。
直接更改连接关系就可以。要先记录一下pos的下一个next,然后连接关系就可以任意变动了。
void ListInsertAfter(ListNode* pos, DataType x)
{
assert(pos);
ListNode* newnode = BuyListNode(x);
ListNode* next = pos->next;
pos->next = newnode;
newnode->next = next;
}
十、单链表在pos位置之后删除
记录一下pos的next的next节点,然后删除pos的next节点,再让pos指向删除节点的下一个节点即可
void ListEraseAfter(ListNode* pos)
{
ListNode* next = pos->next;
while (next != NULL)
{
ListNode* nnext = next->next;
free(next);
pos->next = nnext;
}
}
十一、链表的优缺点
优点:链表的优点很明显,插入和删除的效率都很高,不管是中间插入还是头插尾插。
缺点:缺点就是不适合查找,链表要查找的话只能一个一个的遍历。