目录
链表简介
链表(Linked List)是一种常见的数据结构,用于存储和组织数据。与数组不同,链表中的元素在内存中可以不连续存储,而是通过节点之间的指针链接起来。
链表由一个个节点组成,每个节点包含两部分:数据部分和指针部分。数据部分用于存储实际的数据,而指针部分则用于指向下一个节点的地址。
链表的好处包括:
1. 动态性:链表的长度可以根据需要动态增长或缩小,不像数组那样需要提前定义大小。这使得链表更加灵活,适用于处理未知数量的数据。
2. 插入和删除效率高:由于链表中的节点是通过指针相互连接的,因此在插入和删除节点时只需要修改指针,而不需要移动其他元素。这使得链表在插入和删除操作上具有较高的效率。
3. 空间利用效率高:链表以节点为单位存储数据,不需要像数组那样预先分配固定大小的连续内存空间。这意味着链表可以更好地利用可用的内存空间,避免了内存浪费。
4. 灵活性:链表可以轻松地进行节点的添加、删除和移动操作,无需进行大规模的数据搬迁。这在某些特定的应用场景中非常有用,例如实现栈、队列等数据结构。
链表的结构非常多样,以下情况组合起来有8种链表结构:
1.单向或者双向
单向的链表通过每个节点存储的指针部分来进行链接,由于每个节点只存储了下一个节点的地址,因此在实现尾插的时候非常不方便,需要先找到尾节点(非循环链表尾节点指向空),这时候就需要遍历一次链表。双向的链表则是可以分别找到上一个节点和下一个节点。但注意头尾并没有相连,循环链表才会头尾相连。
2.带头或者不带头(指哨兵位)
head这个哨兵位不存储有效数据,只是用来存储下一个节点的地址,在一些oj题中我们手动创建一个哨兵位有时候能省去一些判断空指针的麻烦。
3.循环或者非循环
循环的话这个链表就成一个环状了,没有任何一个节点是指向空的。环状链表在oj题中的考察也非常经典,值得一做。
本次讲解带头双向循环链表。它看似复杂,其实非常简单,这种链表在实践中使用最多,因为它的结构能带来很多优势,往下看就能明白了。
链表的结构定义
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
// 接口实现涉及的头文件
typedef int LTDataType; // 方便更改存储数据类型
typedef struct ListNode
{
LTDataType val;
struct ListNode* prev; // prev用来存储上一个节点的地址
struct ListNode* next; // next用来存储下一个节点的地址
}ListNode;
要实现的接口函数
// 创建返回链表的哨兵位节点
ListNode* ListCreate();
// 链表打印
void ListPrint(ListNode* pHead);
// 链表尾插
void ListPushBack(ListNode* pHead, LTDataType x);
// 链表尾删
void ListPopBack(ListNode* pHead);
// 链表头插
void ListPushFront(ListNode* pHead, LTDataType x);
// 链表头删
void ListPopFront(ListNode* pHead);
// 链表查找
ListNode* ListFind(ListNode* pHead, LTDataType x);
// 链表在pos的前面进行插入
void ListInsert(ListNode* pos, LTDataType x);
// 链表删除pos位置的节点
void ListErase(ListNode* pos);
// 链表销毁
void ListDestory(ListNode* pHead);
哨兵位节点创建
ListNode* ListCreate()
{
ListNode* pHead = (ListNode*)malloc(sizeof(ListNode));
if (pHead == NULL)
{
perror("malloc fail");
exit(-1);
}
pHead->next = pHead;
pHead->prev = pHead;
pHead->val = -1; // 这里随便塞了个值,可以不塞,哨兵位不存储有效数据
return pHead;
}
这里就创建了一个哨兵位,因为此时只有一个节点,因此让它的prev和next都指向自己即可
链表打印
void ListPrint(ListNode* pHead)
{
assert(pHead); // 链表无论如何不会为空,因为前面创建了哨兵位节点
printf("哨兵位<=>"); // 视觉上模拟双向箭头,更直观的展示
ListNode* cur = pHead->next;
while (cur != pHead) // 让cur从第一个有效节点开始,遇到哨兵位结束
{
printf("%d<=>", cur->val);
cur = cur->next;
}
printf("哨兵位\n"); // 视觉上模拟循环
}
由于下面的函数经常要涉及新节点的创建,所以写一个用来创建节点的函数,方便后面cv编程
ListNode* CreateNode(LTDataType x)
{
ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->val = x; // x是要添加的数据
newnode->next = NULL; // 先置空后面看具体情况来操作
newnode->prev = NULL; // 先置空后面看具体情况来操作
return newnode;
}
链表尾插
void ListPushBack(ListNode* pHead, LTDataType x)
{
assert(pHead);
ListNode* newnode = CreateNode(x);
// 下面四步看着有点绕,画画图就理解了
pHead->prev->next = newnode;
newnode->prev = pHead->prev;
newnode->next = pHead;
pHead->prev = newnode;
}
这里就能发现尾插变得很简单,单向链表尾插还需要找尾,每尾插一次时间复杂度O(N)。而双向带头循环链表用哨兵位就能找到尾节点。并且单向链表尾插的时候还要判断首节点为不为空,这就需要传入头节点指针的地址,才能在头节点为空时改变头节点,而带哨兵位就没有这个烦恼。
链表尾删
void ListPopBack(ListNode* pHead)
{
assert(pHead);
assert(pHead->next != pHead);
// 没节点还删个毛,用assert暴力检查,敢传只有哨兵位的节点就敢报错
// 老三步,tmp这个变量用来保存要删除的节点地址,因为后面链表的指向会变
ListNode* tmp = pHead->prev;
pHead->prev = pHead->prev->prev;
pHead->prev->next = pHead;
free(tmp);
// 释放空间后tmp可以置空也可以不置空,这里tmp是局部变量,函数结束就销毁了
}
链表头插
void ListPushFront(ListNode* pHead, LTDataType x)
{
assert(pHead);
ListNode* newnode = CreateNode(x);
// 是不是很眼熟,插入基本都是四步走,只有略微改变
newnode->next = pHead->next;
newnode->prev = pHead;
newnode->next->prev = newnode;
pHead->next = newnode;
}
链表头删
void ListPopFront(ListNode* pHead)
{
assert(pHead);
assert(pHead->next != pHead); // 暴力检查
// 老三样
ListNode* tmp = pHead->next;
pHead->next = pHead->next->next;
pHead->next->prev = pHead;
free(tmp);
}
链表查找
ListNode* ListFind(ListNode* pHead, LTDataType x)
{
assert(pHead);
ListNode* cur = pHead->next;
while (cur != pHead)
{
if (cur->val == x)
return cur;
cur = cur->next;
}
return NULL;
}
这里返回的是要查找的数据所在的节点的地址,可以配合后面这两个函数来使用
链表在pos位置前插入
void ListInsert(ListNode* pos, LTDataType x)
{
assert(pos);
ListNode* newnode = CreateNode(x);
newnode->prev = pos->prev;
newnode->next = pos;
newnode->prev->next = newnode;
pos->prev = newnode;
}
这里的pos指的是链表某个节点的地址
链表pos位置删除
void ListErase(ListNode* pos)
{
assert(pos);
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
链表销毁(释放空间)
void ListDestory(ListNode* pHead)
{
assert(pHead);
ListNode* cur = pHead->next;
while (cur != pHead)
{
ListNode* tmp = cur->next;
free(cur);
cur = tmp;
}
// 前面循环cur不会等于哨兵位节点,所以手动释放一下
free(pHead);
}
动态开辟的空间切记要释放,以免造成内存泄漏
看到这再来对比一下单向链表的接口实现,是不是觉得带头双向循环链表很容易了呢