前言
上一节中我们学习了typedef
、malloc
,calloc
和realloc
关键字,以及对于结构体struct
的应用,以及最重要的就是我们学习了什么是顺序表,以及顺序表如何实现,如何通过结构体来完成对顺序表的一个初步使用,但是,不得不提的一点是,我们会利用assert
宣言来判断指针是否为空,以及对于一个简单的顺序表的一个完成,但是我们会发现,由于数据的增多,头删、头插都会因此而变得时间复杂度更为乱,不恒定,这个时候我们就引如这一章的主题——链表。我们会发现链表的概念让顺序表的利用更为便捷,从而也方便了我们在后面对于栈以及队列的使用。
顺序表的问题及其思考
- 中间/头部的插入删除,时间复杂度为O(N)
- 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
- 增容一般是呈2倍的增长,势必会有一例如当前容量为100,满了以后增容到 200,我们再继续定的空间浪费。插入了5个数据,后面没有数据插入了那么就浪费了95个数据空间。
在不断的生产生活中,我们发现仅仅靠一个顺序表这些东西对于时间,空间的浪费还是很显著的,特别是在一个大的项目中,我们不妨想一下,既然
其逻辑结构一定连续,但是其物理结构不一定连续。道理很简单,假设我们作为生产绿皮火车的公司之一,我们会给每一个车厢生产编号,
No.26
、No.28
等等,但我们会发现绿皮火车的车厢排序并不会因为生产编号的固定而固定,一列绿皮火车的车厢组合是任意的,也就是说,并不在意生产编号是否连号,而注重的是连接后产生的车厢编号。
我们不难想象到,我们在旅游淡季的时候,绿皮火车也好,高铁也罢,会进行适量的减厢子,在旅游旺季的时候,也会适量进行加厢。~~有一说一感谢广铁U彩让我在国庆放假前最后一晚上候补到第二天下午的十点车票,也感谢美丽的价格让我在中秋时节这个时候还在宿舍写稿。~~我们如果对火车的结构进行过观察,我们会发现火车的车厢,高铁的车厢都是通过车钩或者牵引杆来进行两节相连。我们在想,能不能存在一种链接方式,使得我们的数据之间可以像车厢一样链接起来。
这就不得不提我们对于指针的应用了。
单链表的引入
我们一开始就说到,顺序表主要的是谈及其逻辑结构一定连续,但是其物理结构不一定连续,这样我们就很巧妙地解决了来连续的空间存储数据,而且可以进行一些设计,使得内存的利用率提升,在这里不表。
变量?还是地址?
不难发现,我们对于数据的存储如果单纯只是应用于指针来看的话,那么这个信息也只是存储了地址,并没有什么特别的地方,但是我们如果想到struct
结构体,我们会发现,我们真的做到了——把一个变量以及一个地址放到了里面。而且我们发现借助结构体可以包含更多的数据:
typedef struct Node {
int a; // 链表节点的数据部分
struct Node *next; // 指向下一个节点的指针
//相当于N *next;
}N;
//此时N等价于struct Node
我们发现通过结构体struct
很容易就把我们想要的东西都包含在内,我们在这里再引入跟车钩或者牵引杆相似的功能,我们把我们存放数据源信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称为指针或链。这两部分信息组成数据元素
a
i
a_i
ai的存储映像,称为结点(Node)。
节点(Node)
我们把n个结点的链结成一个链表,即为线性表( a 1 , a 2 , a 3 a_1,a_2,a_3 a1,a2,a3… , a n ,a_n ,an)的链式存储结构,因为此链表的每个结点中只包含一个指针域,也称作单链表。
同样的,每个节点中包含两个指针域的,称作双向链表(指向前一个指针,又指向后一个指针)。
对于线性表来说,总得有个首尾,链表也不例外。我们把链表第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了(这里很重要,后面还会引入"循环链表")。之后的每一个结点,其实就是上一个的后继指针指向的位置。不难想象到,最后一个结点它的指针指向的一定是Null
,空指针。
有时候,我们为了方便对链表的观察,我们会在单链表的第一个结点前设立一个结点,称为头结点。头结点的数据域可以不存储任何信息,我们也可以存储一些关于这个链表的相关信息~
概念区分
头指针
- 头指针是链表指向第一个结点的指针,若链表又头结点,则是指向头结点的指针
- 头指针可以用来区别这个链表的功能(也就是语文中的区别词)
- 无论链表是否为空,头指针均不为空。头指针是链表的必要元素。
头结点
- 头结点是为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(也可以存放链表的长度)
- 有了头结点,对在第一元素结点前插入结点和删除第一节点,其操作与其他结点的操作就统一了。
- 头结点不一定是链表必须要素**(但我觉得何尝不可以有一个呢?方便我们后续写代码的逻辑统一)**
线性表链式存储结构
typedef struct Node {
int a; // 链表节点的数据部分
struct Node *next; // 指向下一个节点的指针
//相当于N *next;
}N;
//此时N等价于struct Node
typedef struct Node *LinkList;//定义了一个链表
我们也可以从这个结构中窥探到,结点由存放数据元素的数据域和存放后继结点地址的指针域组成。假设p是指向线性表第i
个元素的捐针,则该结点
a
i
a_i
ai的数摇域我们可以用p->data
来表示、p->data
的值是一个数据元素,结点a_[i-1]
的指针域可以用p->a_[i-1]
来表示,p->next
的值是一个指针。p->next
指向第i+1
个元素,围指向a_1+1的指针。也就是说,如果p->data
等于a-1
,那么p->next->data
等于a_i+1
.
单链表的读取
获得链表第i
个数据的算法思路:
- 声明一个指针p指向链表第一个结点,初始化
j
从1开始 - 当 j < i j<i j<i时,就遍历链表,让p的指针向后移动,不断指向下一结点, j j j累计+1;
- 若找到链表末尾为空,则说明第
i
个链表不存在; - 否则查找成功,返回结点p的数据
Status GetElem(LinkList L,int i,ElemType *e){
int j;
LinkList p;
p=L->next;
j=1;
while(p&&j<1){
p=p->next;//核心
j++;
}
if(!p||j>1)
return ERROR;
*e =p->data;
return 1;
}
由于链表的特性是我们不知道它有多长,而因此利用p=p->next
这一**”工作指针后移“**,这其实也是很多算法的常用技术。
单链表的插入与删除
插入
不妨这样思考这个问题,我们要实现链表的插入,,只需要绕过a->next
和p->next
的指针做一点小改变就可以*(alert)*.
我们可以注意到,空箭头指向的是原本要指向的内容,而虚线箭头指向的是修改后的链表,也就是我们要修改Node中的Pointer,以及PExample中的Pointer();
PExample->Pointer=Pre->Pointer;
Pre-Pointer=PExample;
相似的,对于单链表的表头和表尾的特殊情况,操作是相同的:
而单链表的表尾添加方法是如下:把表尾的空指针指向下一个数据的地址。
单链表第 i i i个数据插入结点的算法思路:
- 声明一个指针p指向链表头结点,初始化 j j j从1开始;
- 当 j j j<i时,就遍历链表,让p的指针向后移动,不断指向下一结点, j j j累计加1;
- 若到链表末尾 p p p为空,则说明第 i i i个结点不存在;
- 否则查找成功,在系统中生成一个空结点s;
- 将数据元素e赋值给s->data;
- 单链表的插入标准语句s->next=p->next;p->next=s;
- 返回成功
Status ListInsert(LinkList *L,int i,ElemType e){
int j;
LinkList p,s;
p=*L;
j=1;
while(p&&j<i){
p=p->next;
j++;//用++j实际上会更快一点
}
if(!p||j>i)
return ERROR;
s=(LinkList*)malloc(sizeof(Node));
s->data=e;
s->next=p->next;
p->next=s;
return 1;
}
删除
我们通过将其的前继结点的指针绕过,指向它的后继结点即可。
也就是
PExample=Node->next;
Node->Pointer=PExample->Pointer;
也就是单链表第 i i i个数据删除结点的算法,其思路为:
- 声明一个指针p指向链表头结点,初始化 j j j从1开始;
- 当 j j j<i时,就遍历链表,让p的指针向后移动,不断指向下一结点, j j j累计加1;
- 若到链表末尾Next为空,则说明第 i i i个结点不存在;
- 否则查找成功,将要删除的结点PExample->Pointer赋值给Node->Pointer;
- 单链表的删除标准语句Node->Pointer=PExample->Pointer;
- 将PExample->Pointer中的数据赋值给e,作为返回
- 释放结点PExample;
- 返回成功
Status ListDelete(LinkList *L,int i,ElemType *e){
int j;
LinkList p,q;
Node=*L;
j=1;
while(Node->Pointer&&j<i){
Node=Node->Pointer;
++j
}
if(!(Node->Pointer)||j>i){
return ERROR;
}
PExample=Node->Pointer;
Node->Pointer=PExample->Pointer;
*e=PExample->Data;
free(PExample);
return 1;
}
显然,对于插入或删除数据越频繁的操作,单链表的效率优势更为明显。
单链表整个表的创建
不妨回眸一下数组,我们数组就是因为有明确的数据个数,数据类型,并且有赋值才确立的,而相似的有了动态顺序表,我们一直在强调链表是不需要声明个数的,那么也就是说我们不需要明确的数据个数,只需要数据类型就可以定义出一个单链表
创建一个单链表的整体思路:
- 声明一指针p和计数器变量account
- 初始化——空链表L
- 让L的头结点的指针指向NULL,即建立一个带头结点的单链表
- 循环
- 生成一个新节点,并赋值给p
- 随机生成数字赋值给p的数据域p->data;
- 将p插入到头结点与前一新节点之间。
void CreateListHead(LinkList *L,int n){
LinkList p;
int account;
srand(time(0));
*L=(LinkList)malloc(sizeof(Node));
(*L)->next=NULL;
for(i=0;i<n;i++){
p=(LinkList)malloc(sizeof(Node));
p->data=rand()%100+1;
p->next=(*L)->next;
(*L)->next=p;
}
}
这实际上就是始终让新节点在第一的位置。我们也可以称之为头插法
自然我们也有尾插法:
void CreateListHead(LinkList *L,int n){
LinkList p,r;
int i;
srand(time(0));
*L=(LinkList)malloc(sizeof(Node));
r=*L; //r为指向尾部的结点
for(i=0;i<n;i++){
p=(Node*)malloc(sizeof(Node));
p->data=rand()%100+1;
r->next=p; //将表尾终端的结点的指针指向新结点
r=p; //将当前新结点定义为表尾端结点
}
r->next=NULL;
}
单链表整个表的删除
我们之前提过,内存是作为计算机重要的性能指标之一,它的有限性决定了我们在必要时候要删除一些变量占用了内存(局部变量存在的原因之一),当我们不再使用这个单链表的时候,们需要把他们销毁。
删除的算法思路如下:
- 声明一指针p和q
- 将第一个结点赋值给p。
- 循环:
- 将下一结点赋值给q;
- 释放p
- 将q赋值给p
Status CleanList(LinkList *L){
LinkList p,q;
p=(*L)->next;
while(p){
q=p->next;
free(p);
p=q;
}
(*L)->next=NULL;//头指针域为空
return 1;
}
小结
若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。
当线性表中的元素个数变化较大或者不知道具体存储多少个元素个数的时候,最好用单链表结构。
我们学习了这两种线性结构,但我们会发现这在解决形如12个月问题,一周7天的链表表示中还并不能很完美展现,这就需要我们在第四章来定义一个静态链表,双向链表以及循环列表。