目录
一、简要
1、涵盖内容
2、学习要求
二、导入
三、线性链表
1、链式存储结构
2、注意点
四、单链表
1、单链表优点
2、单链表缺点
3、结点类型描述
4、注意点
五、单链表的实现
1、链表的创立
2、链表的操作
3、代码应用
好久没有写基础笔记了,当时为了写基础笔记是为了能重新巩固数据结构,后来因为考研,就改成了数据结构周周练,但是很多同学看完我的线性结构顺序表之后,希望我能继续更新,所以,从今天开始,继续准备数据结构基础笔记,希望大家喜欢。
第二章一共四小节,第三节讲的是链表的相关概念及实现,链表是线性表的链式存储结构,是后续链式存储的基础。链表,大家可以想成一条链子,链子摆放在地上的方式各种各样,但是相邻两个链子是紧紧联系在一起的,普通的单链表和链子的区别在于,普通链子可以通过任意一节到达所有节,而单链表只能从第一节开始,到达任意一节,并且这个过程不可逆。
在本节代码中,我会加上我大量的个人代码理解,包括我思考的一些问题和我自己得到的答案(问题加粗并设为绿色),还有我自己对代码逻辑的理解,如果有哪里写的不是很完善,或者有一些错误的地方,还希望大家能多多提出宝贵意见,本人在此表示感谢。
1、链表的定义、基本操作及特点。
2、单链表的实现(包括链表的建立、插入和删除、检索等)及应用(验证实现算法的正确性)。
1、掌握链表的相关概念;
2、能用C语言编写单链表的相关操作,包括建立,查询,插入,删除,修改等。
3、掌握顺序表和链表的差别和各自的优缺点。
我们知道,顺序结构有如下特点:逻辑关系上相邻的两个元素,在物理位置上也相邻;这个特点的优点在于可以实现随机存取,而缺点在于做插入删除时,需要移动大量元素。
根据简要我们可以知道,链表和顺序表相反,它不要求逻辑上相邻的元素在物理位置上也相邻,那链表有哪些优点,哪些缺点呢?接下来让我们走进链表,一起来看看链表的世界。
链式存储结构即用一组任意的存储单元存储线性表的数据元素(这组存储单元可以连续,也可以不连续)。由于没有物理上的相邻,想要表达每个数据元素及其后继元素的关系,不得不损失一个空间来保存指向其后继的指针。即对于数据元素ai来说,除了存储其本身的信息之外,还需要存储一个指示其直接后继的信息(即直接后继的存储位置)。
这两部分信息组成数据元素ai的存储映像,称为结点(node)。它包括两个域,存储直接后继存储位置的域称为数据域,存储直接后继存储位置的域称为指针域,n个结点链构成一个链表,即为线性表的链式存储结构,如下表。
由于每个结点只包含一个指针域,故又称线性链表或单链表。
1、整个链表的存取必须从头指针开始,头指针指示链表的第一个结点的存储位置。
2、最后一个结点没有直接后继,所以最后一个结点指针为空。
在上面讲链式存储结构时,介绍了单链表,在此不再说单链表的定义。
1.解决了顺序表需要大量移动存储空间的缺点。
2.不需要物理地址连续的空间,对于部分比较零碎的空间可以得到利用,空间利用率较高。
1.单链表附加指针域,浪费存储空间;
2.单链表的元素离散在分布在存储空间中,是非随机存储的存储结构,查找需要从表头开始。
单链表需要一个数据域,存放数据;一个指针域,存放其直接后继结点的地址。
typedef struct LNode {
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;
其中ElemType是用户自定义数据类型,即用户在线性表中数据域存放数据元素的类型,为了方便代码调试和后续的代码有可重用性,暂时取其为int型。
typedef int ElemType;
通常用“头指针”来标识一个单链表,头指针为“NULL”时称之为空表。为了操作上的方便,在单链表第一个结点之前附加一个结点,称为头结点,头结点的数据域可以不存储任何信息(也可以记录表长等相关信息),其指针域指向线性表的第一个元素结点。
头指针和头结点有什么区别吗?还是有什么联系?
我的理解是:头指针始终指向链表的第一个结点,不管带不带头结点,一个单链表可以由其头指针唯一确定,一般用其头指针来命名单链表。
这里的实现应用的是C语言,因为严蔚敏老师这本教材指定的是C语言版。
在所有操作之前,我们需要考虑到需要先定义一些常量,方便在代码中使用,常用的常量宏定义如下:
#define TRUE 1
#define FALSE 0
#define OK 1
#define ERROR 0
#define OVERFLOW -1
同时,为了后续操作方便,我们将相同类型的返回值重新赋给名称:
typedef int ElemType;
typedef int Status;
根据我们对前面所有概念的理解,我们知道,顺序表需要有一个基地址,来保存这个数组,即顺序表,同时,顺序表要有两个长度,一个长度是这个顺序表当前的存储容量是多少,另一个长度表示当前顺序表的数据有多少个。
typedef struct LNode {
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;
在这里,我想过一个问题,在说问题之前,先给大家对比一下线性表和链表的定义:
/*********顺序表*********/
typedef struct {
ElemType *elem;//存储空间基址,用指针表示
int length;//当前长度
int listsize;//当前分配的存储量,(以sizeof(ElemType)为单位)
}SqList;
/*********链表*********/
typedef struct LNode {
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;
大家有没有注意到在创建结构时:顺序表不用在 struct 后面写上SqList,但是链表在 struct 后面写上 LNode。LNode可以不写吗?
我们把LNode注释掉,看看会不会报错:
我的个人理解是:我们在结构体内部应用LNode时是希望它指向下一个结构体结点,要在使用前定义好,如果未定义,它不知道这个LNode和大括号后面的LNode有什么关系,就会报错,如果我们提前定义好,就知道这两个LNode是同一个,就不会重定义,也不会和LinkList出现冲突。
用链表构造一个空的线性表,首先要分配链表的头结点,然后判断是否分配成功,如果成功,让L指向一个空指针,表示建立了一个空的线性链表。即如下三个步骤:
1.分配初始空间
2.判断分配状况
3.置空(指向空指针)
具体代码实现如下:
Status InitList(LinkList &L) {
L = (LinkList)malloc(sizeof(LNode));//产生头结点,并使L指向此头结点
if (!L)//存储分配失败
exit(OVERFLOW);
L->next = NULL;
return OK;
}
首先理解一下汉语,所谓销毁,就是不留痕迹,什么都不剩下,销毁链表,就是要把链表所有数据全部销毁,不仅要把L的所有数据销毁,还有把头结点销毁。
因为链表的特点是:任意的存储单元,所以只能通过指针从前往后一步一步的销毁。每次都销毁L,即销毁头结点,但是销毁头结点,后面的数据就残留在存储器中。为了方便销毁,定义一个LinkList变量q,协助L进行销毁工作。通过q获取L的后继结点,销毁L后,让L指向L的后继,继续进行销毁工作,直到所有元素被销毁。具体步骤如下:
1.定义LinkList变量q。
2.通过循环遍历链表,方便进行销毁;
3. 在循环体内,q获取L的后继,方便后续销毁工作。
4. 释放链表的头结点L,free释放通过malloc创建的头结点。
5. 让L指向q,即实现指针后移。
6.进入下一次循环,执行操作。直到L为空
Status DestroyList(LinkList &L) {
LinkList q;
while (L)
{
q = L->next;
free(L);
L = q;
}
return OK;
}
从链表角度分析,空表和销毁的区别在于,空表有头结点,但是后继结点为空。销毁表后,头结点为空。所以在置空链表的时候,需要从L的后继结点开始,把所有的数据销毁。
所以每次销毁的时候,L不能动,需要两个LinkList 变量作为辅助,逐步销毁。其实通过分析发现,我们可以把置空看做一个头结点,指向了一个有头结点的链表,需要将L指向的这个链表销毁。所以在置空表中,可以参考销毁表的方式
1.创建两个变量p,q。其中p指向L的后继。然后看做是以p为头结点的列表。
2.通过循环遍历链表,方便进行置空;
3. 在循环体内,q获取p的后继,方便后续销毁工作。
4. 释放链表的结点p,free释放通过malloc创建的头结点。
5. 让p指向q,即实现指针后移。
6.进入下一次循环,执行操作。直到除了L的所有结点全部销毁。
7.头结点指向空。
Status ClearList(LinkList &L) {
LinkList p,q;
p = L->next;
while (p)
{
q = p->next;
free(p);
p = q;
}
L->next = NULL;
return OK;
}
这个时候,我们应该就会注意到如下几个问题了:
1.销毁与置空的区别是什么?
这个问题挺幼稚,但是我们必须要弄明白,比如将一栋房子置空,只是将这个房子中所有的家具等一系列东西清除出去,但是这个房子还在,销毁不同,销毁是将这个房子一起销毁了,不仅房子本身不在了,房子所属的地址也将被释放,用作其他用处。
上面这个是线性表的区别,顺序表和链表还有各自的区别。主要体现在置空的区别。
2.置空链表和置空顺序表不一样?
置空顺序表,只需要长度是0,就可以,后面的所有数据没有操作;
置空链表,不仅仅是让L的后继为空。还需要销毁每一个数据结点;
既然都是线性表,他们两个为什么会在置空有差别,主要是因为对结点的定义和操作不同。
3.为什么顺序表不需要销毁每一个结点呢?
链表和顺序表进行置空的操作的时候的不同的根本原因在于创建方式不同。
顺序表在创建的时候,所有元素用的是同一块基址。(心里默默的想起来《迪迦奥特曼》之伊路德人,共用一个大脑),所以在置空的时候,其实置空的是地址,所以地址无需销毁。
链表则不同,创立的时候,只有头结点自己的地址。每增加一个元素,创立一个新的地址,每个结点都有自己独立的地址,所以在置空的时候,必须要把除了头结点的每个结点的地址也销毁。
所以置空的本质是销毁除基地址外的地址。
空表的特征为:表的后继为空,所以只需要判别L的next是否为空就好。
Status ListEmpty(LinkList &L) {
if (L->next)
return TRUE;
else
return FALSE;
}
获取链表的长度的方式是通过指针遍历,直到最后一个元素。L是头结点,一般不放元素,所以计算长度不包括L,从L->next开始计算。步骤如下:
1.建立一个变量i,负责统计遍历次数,即链表长度。建立一个变量p,指向L的后继,并做循环遍历。
2.循环中,第一个循环,如果p不为空,那就i+ 1,变成1;p指向p的后继,i+1。直到p为空。
3.返回i的值,即表的长度。
Status ListLength(LinkList &L) {
int i = 0;
LinkList p = L->next;
while (p)
{
i++;
p = p->next;
}
return i;
}
用e返回线性表中的第i个元素,用链表来实现,则需要从前往后遍历。在遍历查找第i个位置的元素时,可能i超过L的长度,这个时候,在遍历过程中,p会在某一个位置指向空;
所以在遍历过程中要判断当前位序是否小于i并且当前指针是否不为空。当这两个都满足的时候,进行遍历,一方面要让定义的变量j++,向后遍历,与i进行比较。一方面要让p指向其本身的后继。步骤如下:
1.定义变量j,负责和i进行比较,定义一个指针变量p,指向L的后继,方便进行循环遍历。
2.如果p不为空,并且当前位序j小于要找的位置,可以指向后继,进行遍历。p要指向后继,j要++。
(如果i是1,p指向L的后继,这个时候p指向的数据就是要用e 返回的数据,这个时候不做这个循环,如果i>1,那就需要直到j = i时,跳出循环)
3.除了上面的情况之外,还应该有两种情况:
(1)p指向空,或者 j > i,这时候,说明没有找到第i个位置的元素,这时候就要返回错误。
(2)p不指向空,并且 j = i,这个时候,说明当前位置的数据就是要查找的数据,用形参e接收p指向的数据,并返回正确。
Status GetElem(LinkList L, int i, ElemType &e) {
int j = 1;
LinkList p = L->next;
while (p && j < i) {
j++;
p = p->next;
}
if (!p || j > i)
return ERROR;
e = p->data;
return OK;
}
链表需要循环遍历查找,遍历过程中需要做判断,一个判断是用于遍历的指针是否指向空,另一个是当前位置的数据是否与e满足关系compare。
如果存在这样的元素,用一个变量 i 来返回位序。
1.定义变量i,负责返回位序,定义一个指针变量p,指向L的后继,方便进行循环遍历,并判断p指向的位置的数据元素与e是否满足关系compare。
2.做循环遍历,如果p为空,退出循环,返回错误。在遍历的过程中,如果找到了第一个满足的元素,就返回当前的位置,如果没有找到,继续遍历。(如果L的后继满足关系,那就应该返回1,所以最开始的i为0)
注:该方法查找到的是满足compare的第一个元素e。
Status LocateElem(LinkList L, ElemType e, Status(*compare)(ElemType, ElemType)) {
int i = 0;
LinkList p = L->next;
while (p)
{
i++;
if (compare(e,p->data))
return i;
p = p->next;
}
return ERROR;
}
按照位序查找和按照元素查找,对于链表是否不同?
我们知道对于顺序表的查找,按照位置查找和按照元素查找有两方面不同。
1.两个方法的查找方式不同,如果是按照位置查找,直接用位序相关关系求即可,无需遍历。但是按照元素查找,需要从第一个元素开始遍历,直到查到第一个满足compare()位序的元素或者查询到结尾。
2.两种方法的时间复杂度是不同的。按照位置查找属于随机存取,时间复杂度为o(1),按照元素查找,最坏情况下会遍历到最后一个元素,时间复杂度为o(n)。
对于链表来说,这两种方式都需要进行遍历,所以查找方式没有区别,只是查找的内容不同,一个是查找位置,一个是查找元素。两者的时间复杂度是相同的,都是遍历查找,时间复杂度都是o(n)。所以我们可以认为对于链表来说这两种方式没有差别。
函数中第三个参数的含义是什么?
请参考顺序表:第三个参数的含义,与顺序表一致。
接下来的8和9和6和7比较相似,都是查找,不同在于6是通过位置找元素,7是通过元素和关系找位置,8和9是确保存在某元素时找到它的前驱或后继。
要返回L的前驱,与位序无关,也不需要返回位序,就不需要考虑i或者j 的初始位置了。
头结点是没有前驱的,所以如果元素是L的后继的数据的时候,错误,如果不是就可以做遍历,p从L的后继开始遍历因为要找前驱,普通链表(不是双向链表的链表)不能直接指向前驱,为了方便运算,那就判断p的后继的数据是不是要找的数据,如果是那就返回p指向的数据。如果找不到,就返回错误。
1.判断cur_e是否为L的头结点。
2.通过指针变量做循环遍历,若p有后继,说明p可能指向cur_e的前驱。
Status PriorElem(LinkList L, ElemType cur_e, ElemType &pre_e) {
if (cur_e == L->next->data)
return ERROR;
LinkList p = L->next;
while (p->next)
{
if (p->next->data == cur_e)
{
pre_e = p->data;
return TRUE;
}
p = p->next;
}
return ERROR;
}
要返回L某元素的后继,需要从第一个开始查找,查找到元素后,直接获取后继即可。步骤如下:
1.定义一个指针变量,从L的第一个元素开始查找;
2.如果p的后继是空,说明已经查找到了数据表的结尾,p没有后继,所以如果p->next为空时,就不做循环。
3.在循环体内,判断p指针指向地址的数据元素是否为要查找元素,如果是,获取p的后继的数据域的数据元素。如果不是,p指向p的后继继续查询。
Status NextElem(LinkList L, ElemType cur_e, ElemType &next_e) {
LinkList p = L;
while (p->next)
{
if (p->data == cur_e)
{
next_e = p->next->data;
return TRUE;
}
p = p->next;
}
return ERROR;
}
链表插入数据元素比较好的一点在于插入位置之后的元素不需要再移动,只需要将新元素的指针指向第i个元素,再将i的前驱元素指针指向新元素即可。
1.定义一个指针变量,从L的第一个元素开始查找,即L的后继;这个时候查找的是位置,而不是元素,所以查找时同时要获取元素位序。
在这里我想采用一个新的方法,我不创建新的int变量,让位序每次减一,即获取当前查找位置到位置 i 之间的距离,当距离为0时,说明查找到该元素,这样能节省一个额外空间,减少内存消耗。
因为我们希望得到的是 i 的前驱,所以最开始,我们还要 -1 。比如 i = 2,我们要找到 i = 1 的位置,即找到L的后继,最开始也刚好是L的后继,--i 的值是0,不做循环,当 i = 3 时,要找到 i = 2 的位置,做一次循环,i = 1,第二次循环 i = 0 ,不做循环。
除了上述所说,如果输入的是非法数值呢?
在这里,我们先不考虑用户输入非整型数据,如果用户输入的是0,或者是负值,我们可以通过i-->0来区别。如果用户输入了大于表长的数,p最终会成为空,即跳出循环,这个时候,我们在循环语句后面加上一个判断语句即可。如果p == null,或 i<=0,返回溢出。
2.找到 i 的前驱,然后就需要将新元素插入到链表中,定义一个指针q,数据域上的数据为e,q的next指针指向p的next,然后p的后继为q。
Status ListInsert(LinkList &L, int i, ElemType e) {
LinkList p = L->next;
i--;
while (p && i > 0) {
p = p->next;
i--;
}
if (p == NULL || i<0)
return OVERFLOW;
LinkList q = (LinkList)malloc(sizeof(LNode));
q->data = e;
q->next = p->next;
p->next = q;
return OK;
}
删除,需要将第i个元素结点释放,将第i个结点的后继变为i结点的前驱的后继。
1.找到结点 i 的前驱,理论同插入元素一样,不再过多陈述。
2.获取到i结点的前驱的后继,(即i结点本身)用q指针指向该结点,将结点暂存下来,方便后续的释放结点。
3.用 e 获取到 i 结点的数据, i 结点的前驱指向i结点的后继,并释放 q 结点。
Status ListDelete(LinkList &L, int i, ElemType &e) {
LinkList p = L->next;
i--;
while (p && i > 1) {
p = p->next;
i--;
}
if (p == NULL || i < 0)
return OVERFLOW;
LinkList q;
q = p->next;
e = q->data;
p->next = q->next;
free(q);
return e;
}
对于顺序表来说,上面这些操作就可以了,但是对于链表来说,我们应该还要讲三个重要操作:头插法建立单链表,尾插法建立单链表,遍历链表元素。
头插法建立单链表是链表插入元素的应用,即在表头结点后面插入元素。在这里只给出代码,请大家自己分析。有什么问题我们可以共同交流。
Status HeadInsertList(LinkList &L, ElemType e) {
LinkList p = (LinkList)malloc(sizeof(LNode));
p->data = e;
p->next = L->next;
L->next = p;
return OK;
}
Status TailInsertList(LinkList &L, ElemType e) {
LinkList p = L;
LinkList q = (LinkList)malloc(sizeof(LNode));
q->data = e;
q->next = NULL;
while (p->next)
p = p->next;
p->next = q;
p = q;
return OK;
}
头插法和尾插法哪个好用?
头插法和尾插法都是插入元素的应用,通过比较我们发现它们最大的差别主要在于是否需要遍历链表。头插法和尾插法是插入元素的两个极端,头插法无需遍历链表,尾插法需要遍历整个链表。所以一般情况下,为了使时间复杂度最小,采用头插法建立单链表。
void OutputList(LinkList L) {
LinkList p = L->next;
int i = 0;
while (p)
{
cout << p->data << '\t';
i++;
if (i%5 == 0)
{
cout << endl;
}
p = p->next;
}
cout << endl;
}
接下来我们测试一下我们写的函数。
void main() {
LinkList L;
ElemType e = 0;
InitList(L);
//——————头插法建立单链表——————
cout << "头插法建立单链表。\n";
for (int i = 5; i > 0; i--) {
HeadInsertList(L, i);
}
OutputList(L);
//——————尾插法建立单链表——————
cout << "尾插法建立单链表。\n";
for (int i = 6; i < 10; i++) {
TailInsertList(L, i);
}
OutputList(L);
//——————删除第五个元素——————
cout << endl;
cout << "删除第五个元素。\n";
ListDelete(L, 5, e);
cout << "删除的位置的元素是" << e << endl;
OutputList(L);
}
【输出结果】
分析码字不易,希望大家喜欢。