线性表的链式存储结构正是所谓的单链表,何谓单链表?通过地址将每一个数据元素串起来,进行使用,这可以弥补顺序表在进行任意位置的插入和删除需要进行大量的数据元素移动的缺点,只需要修改指针的指向即可;单链表的种类又可划分为很多种,本篇博客详细介绍带头结点单链表的设计与实现,掌握单链表的关键是要进行画图分析;单链表同时也是笔试和面试的必考点,因此,掌握好该章节非常重要!
目录
一、单链表的基本概念和结构
线性表的链式存储结构正是所谓的单链表,那么什么是链式存储结构?线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。这就意味着,这些数据元素可以存在内存未被占用的任意位置。链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
1.1 带头结点单链表的概念
单链表的整体结构由一个个的结点组成,每个结点类型包括两个部分:存储的数据元素(数据域)和存放下一个结点地址的指针域(它是一个指向下一个结点的指针,存储的是下一个结点的地址),所谓带头结点,是因为它存在一个标记结点,它的数据域可以不指定,它的指针域存储的是第一个有效结点的地址,通过指针域便可以访问每一个结点,尾结点是最后一个数据元素,因此它的指针域为:NULL;
带头结点的单链表主要包括两部分:指向头结点的指针,即头指针和存储单链表有效数据元素个数的size变量;
请注意,与顺序表不同,单链表的结点是按需向堆区动态申请,而不是直接进行扩容,用一个结点,向堆区申请一个结点,因此,它不需要来记录链表总容量的capacity变量,同时,它也不会有判满操作,但是有判空操作。
头结点与头指针的异同:
1.2 带头结点的单向链表的结构
如上所示,清晰的展示了带头结点的单链表结构,需要注意的是:单链表的每一个结点是在堆区进行申请的,而单链表的头指针和有效数据元素个数变量是在栈区开辟的!
二、单链表的分类
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
2.1. 单向或者双向
2.2 带头或者不带头
2.3 循环或者非循环
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:
1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
2. 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
三、带头结点的单向链表接口实现
单链表的基本操作有:初始化,头插,尾插,按位置插,头删,尾删,按位置删,查找,按值删,获取有效值个数,判空,清空,销毁,打印。这里详细展示这些基本操作的实现思想和画图分析以及代码实现和算法效率分析,
注意:单链表与顺序表不同,由于它是按需索取,因此,不需要进行判满和扩容操作;
//引入必要的头文件
#include "SeqList.h"
#include<assert.h>
#include<stdlib.h>
//初始化
void Init_list(PNode plist);
//头插
bool Insert_Head(struct Node *plist, ELEM_TYPE val);
//尾插
bool Insert_Tail(struct Node *plist, ELEM_TYPE val);
//按位置插
bool Insert_pos(struct Node *plist, int pos, ELEM_TYPE val);
//头删
bool Del_head(struct Node* plist);
//尾删
bool Del_tail(struct Node *plist);
//按位置删
bool Del_pos(struct Node *plist, int pos);
//按值删
bool Del_val(struct Node *plist, ELEM_TYPE val);
//查找节点
struct Node* Search(struct Node *plist, ELEM_TYPE val);
//获取有效值个数
int Get_Length(struct Node *plist);
//判空
bool IsEmpty(struct Node *plist);
//清空
void Clear(struct Node *plist);
//20:10
//销毁
void Destroy(struct Node *plist);
//打印
void Show(struct Node *plist);
3.1 单链表结点设计(结构体)
每个结点包括两个部分:存储数据的数据域和指针域(指向下一个结点/存储下一个结点地址的指针)构成。因此设计结点主要设计这两个成员变量。
强调结构体自身引用(自己嵌套自己必须使用struct,即使使用typedef关键字进行重命名)结构体内部不可以定义自身的结构体变量,但是可以定义自身结构体指针变量,因为指针与类型无关,占用内存空间就是4个字节!
typedef int ELEM_TYPE;
//带头结点的单链表
//有效节点的结构体设计
typedef struct Node
{
ELEM_TYPE data; //数据域 //保存节点有效值
struct Node *next; //指针域 //保存下一个有效节点的地址
}Node, *PNode;
//typedef struct Node Node;
//typedef struct Node* PNode;
3.2 单链表的初始化
单链表的初始化主要是对其指针域赋值,数据域不使用,不需要操作!
void Init_list(PNode plist)
{
第0步:传入的指针参数检测
assert(plist!=NULL);
第1步:对指针域赋初值
plist->next = NULL;
//plist->data; 头结点的数据域不使用,不需要赋值
}
3.3 头插
头插的基本思路如下:
第0步:assert对传入的指针检测;
第1步:购买新节点(购买好节点之后,记得将val值赋值进去);
第2步:找到合适的插入位置;
第3步:插入,注意核心代码,先牵右手,再牵左手!!!否则会发生内存泄漏。
//头插
bool Insert_Head(struct Node *plist, ELEM_TYPE val)
{
//第0步:assert对传入的指针检测
assert(plist != NULL);
//第1步:购买新节点(购买好节点之后,记得将val值赋值进去)
struct Node *pnewnode = (struct Node *)malloc(1 * sizeof(struct Node));
assert(pnewnode != NULL);
pnewnode->data = val;
//第2步:找到合适的插入位置
//因为是头插函数 所以不需要特意的去合适的位置 直接向plist后面插即可
//第3步:插入;
分两步:先让新购买的节点也指向第一个有效节点,这时,头结点的指针域就可以指向新购买的节点了
pnewnode->next = plist->next;
plist->next = pnewnode;
return true;
}
3.4 尾插
尾插的基本思路如下:
第0步:assert对传入的指针检测;
第1步:购买新节点(购买好节点之后,记得将val值赋值进去);
第2步:找到合适的插入位置,在这里就是找到最后一个有效结点,如何找?因为最后一个有效结点的指针域为空,只需要从头开始通过地址,遍历每一个结点,直到遇到最后一个节点,此时指针域为空;
第3步:利用插入的通用代码!
问题:遍历结点的开始位置如何选取?
(1)如果是需要前驱结点操作的函数,比如:插入、删除,就让遍历的结点一开始指向头结点;!!!
(2)如果是不需要前驱结点操作的函数,比如:获取有效数据元素个数、打印,就让遍历的结点一开始指向第一个有效结点;!!!
//尾插
bool Insert_Tail(struct Node *plist, ELEM_TYPE val)
{
//1.assert plist
assert(plist != NULL);
//2.购买新节点(购买好节点之后,记得将val值赋值进去)
struct Node *pnewnode = (struct Node *)malloc(1 * sizeof(struct Node));
assert(pnewnode != NULL);
pnewnode->data = val;
//3.找到合适的插入位置
struct Node *p = plist;
for(p; p->next!=NULL; p=p->next);
//此时 p就指向尾结点
//4.插入
pnewnode->next = p->next;
p->next = pnewnode;
return true;
}
3.5 按位置插入
按位置插入的基本思路如下:
第0步:assert对传入的指针和插入的位置检测;要插入的位置必须大于等于零且小于等于 链表总长度。
第1步:购买新节点(购买好节点之后,记得将val值赋值进去);
第2步:找到合适的插入位置,在这里就是找到插入位置的前一个结点,如何找?用指针p指向(例如pos=2,则让临时指针p,从头结点开始向后走pos步)
第3步:利用插入的通用代码!
//按位置插
bool Insert_pos(struct Node *plist, int pos, ELEM_TYPE val)
{
//assert
assert(plist != NULL);
assert(pos>=0 && pos<=Get_Length(plist));
//1.购买新节点(购买好节点之后,记得将val值赋值进去)
struct Node *pnewnode = (struct Node *)malloc(sizeof(struct Node));
assert(pnewnode != NULL);
pnewnode->data = val;
//2.找到合适的插入位置(让指针p指向合适的节点)
struct Node *p = plist;
for(int i=0; i<pos; i++)
{
p=p->next;
}
//3.插入
pnewnode->next = p->next;
p->next = pnewnode;
return true;
}
3.6 头删
对于删除操作,则需要对链表进行判空操作!并且删除操作遵循基本同样的4个步骤,需要理解加记忆。删除操作的基本思路如下:
①:用指针q指向待删除节点;
②:用指针p指向待删除节点的前驱节点;(头删的话,这里p可以被plist代替)
③:跨越指向;
④:释放待删除节点。
1.先用一个临时指针q指向第一个有效结点(是因为第二步修改头结点的next域之后,就没有人可以找到待删除节点了)
2.在第一步的基础上,再去修改头结点的next域,让头结点的next不再保存第一个有效节点的地址,而是保存第二个有效节点的地址。
void Del_head(PNode plist)
{
//0.assert 判空
if(Empty(ls))
{
return;
}
//①:用指针q指向待删除节点
struct Node *q = plist->next;
//②:用指针p指向待删除节点的前驱节点(头删的话,这里p可以被plist代替)
//③:跨越指向
plist->next = q->next;
//④:释放待删除节点
free(q);
q=NULL;
}
3.7 尾删
尾删的基本思路还是那四个步骤,只是具体实现的方式不一样。
void Del_tail(PNode plist)
{
//0.assert 判空
assert(plist != NULL);
//1.删除需要判空(判空链表)
if(IsEmpty(plist))
{
return false;
}
//①:用指针q指向待删除节点(最后一个有效节点)
struct Node *q = plist;
for( ; q->next != NULL; q=q->next);
//②:用指针p指向待删除节点的前驱节点(倒数第二个节点)
struct Node *p = plist;
//for(; p->next->next != NULL; p=p->next);
for(; p->next != q; p=p->next);
//③:跨越指向
p->next = q->next;
//④:释放待删除节点
free(q);
q=NULL;
}
3.8 按位置删除
根据位置删除结点,需要判断结点的合法性,这次的pos需要小于链表长度,基本思路还是那四个步骤,只是具体实现的方式不一样。
//按位置删
void Del_pos(PNode ls, int pos)
{
//0.对plist 断言 pos做合法性判断
assert(plist != NULL);
assert(pos >=0 && pos < Get_Length(plist));
//1.删除需要判空(判空链表)
if(IsEmpty(plist))
{
return false;
}
//①:用指针q指向待删除节点
struct Node *q = plist;
for(int i=0; i<pos+1; i++)
{
q=q->next;
}
//②:用指针p指向待删除节点的前驱节点
struct Node *p = plist;
for(int i=0; i<pos; i++)
//pos="几",则让指针q从头结点开始向后跑"几"步(此时,p指向待删除节点的上一个节点)
{
p=p->next;
}//此时,for循环结束,指针q指向待删除节点的上一个节点
//③:跨越指向
p->next = q->next;
//④:释放待删除节点
free(q);
q=NULL;
}
3.9 按值删
按值删需要先找到数据域是该值的结点,然后将其删除,基本思路还是那四个步骤,只是具体实现的方式不一样。
//按值删(从前向后第一个值为val的节点删除掉)
void Del_val(struct Node *plist, ELEM_TYPE val)
{
//0.assert 判空
assert(plist != NULL);
assert(pos >=0 && pos < Get_Length(plist));
//1.删除需要判空(判空链表)
if(IsEmpty(plist))
{
return false;
}
//①:用指针q指向待删除节点
struct Node *q = Search(ls, val);//用指针q去接收Search函数的返回值
if(q == NULL) //如果q==NULL 代表val不存在
{
return;
}
//执行这一行时,代表val值节点存在,且此时用q指向
//②:用指针p指向待删除节点的前驱节点
struct Node *p = plist;
for(; p->next!=q; p=p->next); //让指着p停留在指针q的上一个节点位置
此时p和q分别已经指向了待删除节点和待删除节点的上一个节点,则此时直接跨越指向,并且释放待删除节点
//③:跨越指向
p->next = q->next;
//④:释放待删除节点
free(q);
q=NULL;
}
3.10 查找
按值查找,返回该值的结点,查找操作只需要定义一个临时结点类型指针变量,让它从第一个有效节点开始遍历,只要结点存在就往后遍历。这里的循环条件:p!=NULL,意思是从第一个有效节点遍历到最后一个结点(只要结点存在,就往后走)
struct Node* Search(struct Node *plist, ELEM_TYPE val)
{
第0步:对传入的指针断言检测
assert(plist != NULL);
第1步:定义临时指针变量从第一个有效节点开始遍历
struct Node *p = plist->next;
for(; p!=NULL; p=p->next)
{
if(p->data == val)
{
return p;
}
}
return NULL;
}
3.11 获取有效值个数
只需要定义一个临时结点类型指针变量,让它从第一个有效节点开始遍历,只要结点存在就往后遍历。 采用计数器思想,只要结点存在,计数器加1,返回计数器变量即是有效结点个数。
//获取有效长度
int Get_length(struct Node *plist)
{
struct Node *p = plist->next;
int count = 0;
for( ; p!=NULL; p=p->next)
{
count++;
}
return count;
}
3.12 判空
在进行删除操作时,需要对链表进行判空操作,如果链表为空,则无法删除!!如何判断链表为空?在链表只有一个头结点时,代表链表为空,此时就是最初始的状态,只有一个头结点,并且头结点的指针域为空,因此,只需要判断头结点的指针域是否为空,便可以知道链表是否为空。
//判空
bool IsEmpty(struct Node *plist)
{
assert(plist != NULL);
//如果头结点的next域为NULL,则代表没有有效节点,为空链表
return plist->next == NULL;
}
3.13 销毁
第一种:无限头删
只要链表不为空,一直调用头删函数,直到把所有结点删除完,此时,退出循环。
//销毁1(无限头删)
void Destroy1(struct Node *plist)
{
assert(plist!=NULL);
/*while(!IsEmpty(plist))
{
Del_head(plist);
}*/
while(plist->next != NULL) //链表不为空,一直进行头删操作
{
//①:用指针q指向待删除节点
struct Node *q = plist->next;
//②:用指针p指向待删除节点的前驱节点(头删的话,这里p可以被plist代替)
//③:跨越指向
plist->next = q->next;
//④:释放待删除节点
free(q);
q=NULL;
}
}
第二种:不借助头结点,但是需要两个指针变量p和q(双指针思想)。
//销毁2(不借助头结点, 需要两个指针)
void Destroy(struct Node *plist)
{
assert(plist!=NULL);
struct Node *p = plist->next;
struct Node *q = NULL;
while(p != NULL)
{
q = p->next;
free(p);
p = q;
}
plist->next = NULL;
}
3.14 打印
只需要定义一个临时结点类型指针变量,让它从第一个有效节点开始遍历,只要结点存在就往后遍历,同时打印结构体的数据域成员。
//打印
void Show(struct Node *plist)
{
struct Node *p = plist->next;
for(; p!=NULL; p=p->next)
{
printf("%d ", p->data);
}
printf("\n");
}
四、总结两种循环判断条件
五、顺序和链表的区别
以上便是我为大家带来的带头结点的单链表设计内容,若有不足,望各位大佬在评论区指出,谢谢大家!可以留下你们点赞、收藏和关注,这是对我极大的鼓励,我也会更加努力创作更优质的作品。再次感谢大家!