一、基本概念
链表:链式存储的线性表,简称链表。
学习过顺序表的小伙伴都应该了解,顺序表的存储空间,数据是挤在一起的,对数据进行增删操作时需要将数据成片地移动。而存储空间为离散式的链表就提供了一种解决方案。顺序表和链表在内存上的基本形式如图所示:
根据链表各个节点之间使用的指针个数,以及首尾节点是否相连,可以将链表细分为以下几种类型
单向链表
单向循环链表
双向链表
双向循环链表
这些链表的操作基本相同,区别只在于指针的数目不同,本文先以最简单的单向链表为例,展示链表的基本操作。
对单链表进行操作,首先要了解单链表的结构,单向链表结构示意图:
每一个节点都分为数据域和指针域两部分。其中,Head为单链表的头结点,头结点的数据域不存放数据,指针域指向下一节点。初始化时,头结点的指针域指向空(NULL)。
从头结点的下一个节点开始,才在节点的数据域(Data)存放数据,指针域存放下一节点的地址,通过该地址可以访问该节点的下一个节点,最后一个节点的指针域,指针指向空。
有的单链表头结点的数据域也存放数据,被称为无头结点或不带头结点链表,链栈就是一个不带头结点的单向链表,前面我就写过一篇关于链栈的文章,感兴趣的小伙伴可以去看一下,附上链接:https://blog.csdn.net/weixin_44358957/article/details/129337308
好了,言归正传,接下来我们继续讨论带头结点的单向链表。
二、带头结点的单向链表
一、单向链表的节点设计
单链表的节点包括数据域和指针域两部分,其节点设计如下:
typedef struct node{
int data;//数据域,这里以整型数据为例,也可以是其他类型
struct node *next;//指针域,用来存放下一个节点的地址,指向下一个节点
}Node;
Node *SL = NULL;//定义一个单链表,未真正初始化,需要进行初始化分配内存空间才能使用
二、单向链表的初始化
带头结点的单链表,其头结点是不存放数据的,单链表初始化时,只有头结点,所以在初始化时,让其指针域指向空。
bool ListInit(Node* &SL)
{
//分配空间,如果申请分配内存失败,返回false
if((SL = (Node *)malloc(sizeof(Node))) == NULL)
return true;
//内存申请成功
SL->next = NULL;
return true;
}
三、判断链表是否为空
判断单链表是否为空,只需要判断单链表的头结点的指针域是否指向空。
bool ListsEmpty(Node* SL)
{
//为空,返回true
if(SL->next == NULL)
return true;
//不为空,返回false
return false;
//还有一种更简洁的写法,那就是直接返回
//return SL->next == NULL;
}
四、增加节点
单向链表增加节点的常用方式有两种,分别是头插法、尾插法。
单链表的头插法是指,新的数据节点插入到头结点之后,头结点的下一个节点之前。
//头插法
bool ListHeadAdd(Node* &SL,int data)
{
//如果链表未初始化,返回false
if(NULL == SL)
return false;
//插入新节点需要申请新的节点空间
Node *newnode = (Node *)malloc(sizeof(Node));
if(newnode != NULL)//新节点内存申请成功
{
newnode->data = data;
newnode->netx = SL->next;
SL->next = newnode;
return true;
}
//内存未申请成功,返回false
return false;
}
单链表的尾插法需要先遍历到链表的最后一个节点,将节点插入到最后一个节点后面。
//尾插法
bool ListTailAdd(Node* &SL,int data)
{
//如果链表未初始化,返回false
if(NULL == SL)
return false;
Node *newnode = (Node *)malloc(sizeof(Node));
newnode->data = data;
//遍历整个链表,找到最后一个节点
Node *p = SL;
while(p->next != NULL)//该循环结束后,遍历完链表,p指向最后一个节点,p->next为NULL
{
p = p->next;
}
//遍历到最后一个节点,插入新节点
newnode->next = p->next;
p->next = noewnode;
return true;
}
五、删除节点
删除节点也通常有两种方法,分别是从头部删除节点和从尾部删除节点,删除节点其实只是将节点剔除出链表,对于被剔除的链表节点,只有将其空间释放,才算是彻底删除了节点。
从头部删除节点,即删除头结点的下一个节点
//从头部删除
bool ListHeadDele(Node* &SL)
{
//未初始化或链表为空,返回false
if((SL == NULL) || (SL->next == NULL))
return false;
//删除节点需要释放空间,所以需要一个临时指针变量指向要被释放的空间
Node *tmp = SL->next;
//从链表中剔除tmp节点
SL->next = tmp->next;
//释放被删除的节点,即真正地删除被剔除的节点
free(tmp);
tmp = NULL;//防止tmp成为野指针
return true;
}
从尾部删除,遍历到尾结点,将尾结点删除
bool ListTailDele(Node* &SL)
{
//未初始化或链表为空,返回false
if((SL == NULL) || (SL->next == NULL))
return false;
Node *p = SL;
while(p->next->next != NULL)//该循环结束后,p指向倒数第二个节点,尾结点为p->next
{
p = p->next;
}
//释放尾节点的空间
free(p->next);
//剔除尾节点,让倒数第二个节点的指针域指向空,变成新的尾结点,
p->next = NULL;
return true;
}
六、遍历链表
遍历链表就是从头结点开始读取链表数据,一直读取到尾结点,这里直接打印链表。
void ListTraverse(Node *SL)
{
if((SL == NULL) || (SL->next == NULL))
{
printf("链表未初始化或链表为空!\n");
return;
}
//链表不为空
Node *tmp = SL;
printf("链表元素: ");
while(tmp->next != NULL)//循环结束,tmp指向最后一个元素
{
printf("%d ",tmp->data);
tmp = tmp->next;
}
//打印最后一个元素
printf("%d\n",tmp->data);
}
七、销毁链表
销毁链表,需要遍历链表的每一个节点,将每一个节点的空间释放。
void ListDestroy(Node* &SL)
{
if(SL == NULL)//如果链表没有初始化,不用销毁
return;
Node *p = SL,*q = p->next;//q的作用是,在释放当前节点时,记录当前节点的下一个节点
while(q != NULL)//从头结点开始释放节点空间,一直遍历到尾结点,循环结束,p指向尾结点
{
free(p);
p = q;
q = p->next;
}
//释放尾结点,此时 q = NULL,不需要释放
free(p);
p = NULL;//防止成为野指针
}
三、链表的优缺点
链式存储中,所有节点的存储位置是随机的,他们之间的逻辑关系用指针来确定,跟物理存储位置无关,因此从上述示例代码可以很清楚看到,增删数据都非常迅速,不需要移动任何数据。
另外,又由于位置与逻辑关系无关,因此也无法直接访问某一个指定的节点,只能从头到尾按遍历的方式一个个找到想要的节点。简单讲,链式存储的优缺点跟顺序存储几乎是相对的。其优缺点总结如下:
优点:
插入、删除时无需移动任何数据,只需要对指针进行操作。
当数据节点的数量较多时,无需一整片较大的连续内存空间,可以灵活利用离散的内存。
当数据节点数量变化剧烈时,内存的分配和释放灵活,速度快。
缺点:
在节点中,需要多余的指针来记录节点之间的关联。
所有数据都是随机存储的,不支持立即访问任意一个节点数据。