线性表的链式存储结构
概念
为了表示每个数据元素 ai 与其直接后继元素 ai+1 之间的逻辑关系,对于数据元素 ai 来说,除了要存储其本身的信息之外,还需要存储一个指示其直接后继的信息(即直接后继的物理位置)。
将存储数据元素信息的域称为数据域,把存储直接后继位置的域成为指针域。指针域中存储的信息成为指针或链,这两个部分信息组成数据元素 ai 的存储映像,称为结点(Node)。
n个结点( ai 的存储映像)链结成一个链表,即为线性表 a1,a2,...,an 的链式存储结构,因此此链表的每个结点只包含一个指针域,所以称其为单链表。
将链表中第一个结点的存储位置叫做头指针,为了方便对链表进行操作,会在单链表的第一个结点前附设一个结点,称其为头结点。
下面描述头指针和头结点的异同:
头指针 头结点 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针 头结点是为了操作的方便设定的,放在第一元素的结点之前,其数据与一般无意义(也可存储链表长度) 头指针具有标识作用,所以常用头指针冠以链表的名字 有了头结点,对在第一元素结点前插入和删除第一结点的操作就与其他节点统一 无论链表是否为空,头指针均不为空,头指针是链表的必要元素 头结点不一定是链表的必须要素
在C语言中,单链表可以用结构指针来描述,如下所示:
typedef struct Node{ //结点
ElemType data; //数据域
struct Node *next; //指针域
}Node;
type struct Node *LinkList; /*定义单链表 LinkList*/
单链表
读取链表第i个数据的操作
思路:声明一个指针 p 指向链表第一个结点,初始化 j 从1开始;当j < i 时,遍历链表,让 p 的指针不断向后移动,不断指向下个结点, j 累加1;若到链表末尾 p 为空了,则说明这个结点不存在;否则就查找成功,返回结点 p 的数据。核心思想是“工作指针后移”,时间复杂度为 O(n) 。实现代码如下:
/*初始条件:顺序线性表L已存在,1<=i<=ListLength(L)*/
/*操作结果:用e返回L中第i个元素的值*/
Status GetElem(LinkList L,int i,ElemType e){
int j; //j为计数器
LinkList p; //声明一个指针
p=L->next; //让p指向链表L的第一个结点
j=1;
while(p && j<i){ //循环继续条件,不使用for
p=p->next; //让p指向下一结点
++j;
}
if(!p||j>i) return ERROR; //第i个结点不存在
*e=p->data; //获取第i个结点的数据
return OK;
}
单链表第i个数据插入结点操作
思路:声明一指针p指向链表头结点,初始化j从1开始;当 j < i时,遍历链表,让p的指针后移,不断指向下一结点,j累加1;若到链表末尾,则说明第i个结点不存在;否则,查找成功,在系统中生成一个空的s结点;将数据元素e赋值给s->data;单链表的插入标准语句 s->next=p->next; p->next=s; 注意这两句的位置不能更换,否则插入后面的数据会丢失;返回成功。实现代码如下所示:
/*初始条件:顺序线性表L已存在,1<=i<=ListLength(L)*/
/*操作结果:在L中第i个结点位置之前插入新的数据元素e,L的长度+1*/
Status ListInsert(LinkList *L,int i,ElemType e){
int j; //j为计数器
LinkList p,s; //声明两个指针,一个用于遍历,一个用于生成新结点
p=*L; //头指针
j=1;
while(p && j<i){ //循环继续条件,不使用for
p=p->next; //让p指向下一结点
++j;
}
if(!p||j>i) return ERROR; //第i个结点不存在
/*生成新结点(C标准函数)*/
s=(LinkList)malloc(size(Node));
s->data=e;
s->next=p->next; //插入结点的固定语句
p->next=s; //插入结点的固定语句
return OK;
}
单链表第i个数据删除结点操作
思路:声明一指针p指向链表头结点,初始化j从1开始;当 j < i时,遍历链表,让p的指针后移,不断指向下一结点,j累加1;若到链表末尾,则说明第i个结点不存在;否则,查找成功,将要删除的结点p->next赋值给q;单链表的插入标准语句 p->next=q->next; 将q结点中的数据赋值给e,作为返回;释放q结点,返回成功。实现代码如下所示:
/*初始条件:顺序线性表L已存在,1<=i<=ListLength(L)*/
/*操作结果:删除L中的第i个结点,并用e返回其值,L的长度-1*/
Status ListDelete(LinkList *L,int i,ElemType *e){
int j; //j为计数器
LinkList p,q; //声明两个指针,一个用于遍历,一个用于指向删除结点
p=*L; //头指针
j=1;
while(p && j<i){ //循环继续条件,不使用for,找到第i-1个结点
p=p->next; //让p指向下一结点
++j;
}
if(!p||j>i) return ERROR; //第i个结点不存在
q=p->next; //q指向要删除结点
p->next=q->next; //删除第i个结点
*e=q->data; //保存要删除结点的数据
free(q); //释放内存,回收该结点
return OK;
}
单链表的整表创建操作
思路:声明一指针p和计数器变量i;初始化一个空链表L;让L的头结点的指针指向NULL,即建立一个带有头结点的单链表;循环(生成一个新结点赋值给p,随机生成一数字赋值给p的数据域p->data,将p插入到头结点和前一新结点之间)。这种方法为头插法,实现代码如下所示:
/*建立带有表头结点的单链线性表L(头插法:新结点始终在第一位)*/
void CreateListHead(LinkList *L,int n){
int i;
LinkList p;
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; //随机生成1-100 以内的数字
p->next=(*L)->next; //插入在头结点和最新结点之间
(*L)->next=p;
}
}
/*建立带有表头结点的单链线性表L(尾插法:新结点始终在最后)*/
void CreateListHead(LinkList *L,int n){
int i;
LinkList p,r;
srand(time(0)); //初始化随机数种子
*L=(LinkList)malloc(sizeof(Node)); //初始化空的单链表
r=*L; //r为指向链表末尾结点的指针
for(i=0;i<n;i++){
p=(LinkList)malloc(sizeof(Node)); //生成新结点
p->data=rand()%100+1; //随机生成1-100 以内的数字
r->next=p; //将末尾的指针指向新结点
r=p; //将新结点定义为新的尾部结点
}
r->next=NULL; //表示当前链表结束
}
单链表的整表删除操作
思路:声明一指针p和q;将第一个结点赋值给p;循环(将下一结点赋值给q,释放p,将q赋值给p)。实现代码如下所示:
/*初始条件:顺序线性表L已经存在,操作结果:将L重置为空表,清空到只剩头结点*/
Status ClearList(LinkList *L){
LinkList p,q;
p=(*L)->next; //p指向单链表的第一个结点
while(p){ //没有到达表尾
q=p->next; //q指向下一结点
free(p); //释放p结点
p=q; //将q赋值给p,相当于指针后移
}
(*L)->next=NULL; //头结点的指针域为空
return OK;
}
小结
将单链表结构和顺序存储结构进行对比,如下所示:
存储分配方式 | 时间性能 | 空间性能 |
---|---|---|
顺序存储结构用一段连续的存储单元依次存储线性表的数据元素 | 查找(顺序存储结构 O(1);单链表 O(n)) | 顺序存储结构需要预分配存储空间,分大了,浪费,分小了容易发生上溢 |
单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素 | 插入和删除(顺序存储结构需要平均移动表长一半的元素,时间复杂度为 O(n) ;单链表在找出某位置的指针后,插入和删除的时间仅为 O(1)) | 单链表不需要分配存储空间,只要有可以分配的即可,元素的个数也不受限制 |
结论:
1.若查找频繁,而插入删除操作很少时,应该采用顺序存储结构;若插入删除频繁,应该采用单链表结构。
2.当线性表中的元素个数变化比较大时或者根本不知道多大时,最好使用单链表结构,这样可以不考虑存储空间大小的问题。
各有优缺点,根据实际情况来综合平衡确定使用的数据结构。
静态链表
用数组来代替指针来描述单链表,数组的元素由两个数据域来组成,data(数据域)和cur(类于指针域,存放后继元素的下标,游标)。用数组描述的链表就叫做静态链表(游标实现法)。
/*线性表的静态链表存储结构*/
#define MAXSIZE 1000 /*大小可以再大些,避免数据溢出*/
typedef struct{
ElemType data;
int cur; /*游标(Cursor),为0时表示无指向*/
}Component,StaticLinkList[MAXSIZE];
数组的第一个和最后一个元素做特殊元素处理,不存储数据。第一个元素,下标为0,指向备用链表的第一个结点下标;最后一个元素,存放第一个有数值的元素的下标,相当于头结点作用。
元素的插入操作
思路:用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放,所以需要自己实现结点申请和节点释放两个函数。将所有未被使用过的及已被删除的分量用游标链成一个备用的链表,每当进行插入操作时,就从备用链表上取得第一个结点作为待插入的新结点。实现代码如下:
/*若备用空间链表非空,则返回分配的结点下标,否则返回0*/
int Malloc_SLL(StaticLinkList space){
int i=space[0].cur; //备用链表第一个结点的下标
if(space[0].cur)
//因为要拿一个分量使用,所以要取出下一个分量备用。
//因为要占用一个备用链表结点,所以第一元素的cur值也需要变化
space[0].cur=space[i].cur;
return i;
}
/*在L中第i个元素之前插入新的数据元素e*/
Status ListInsert(StaticLinkList L,int i,ElemType e){
int j,k,l; //j为计数器
k=MAX_SIZE-1; //k是最后一个元素的下标
if(i<1||i>ListLength(L)+1) return ERROR; //插入位置错误
j=Malloc_SLL(L); //获得空闲分量的下标
if(j){ //备用链表存在空闲位置
L[j].data=e; //将数据赋值给对应位置元素
//找到第i个元素之前的位置,相当于不断向后遍历
for(l=1;l<i-1;l++) k=L[k].data;
//插入语句,将第i个元素游标赋值给新元素,第i-1个元素的游标指向新元素
L[j].cur=L[k].cur;
L[k].cur=j;
return OK;
}
return ERROR;
}
元素的删除操作
思路:用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放,所以需要自己实现结点申请和节点释放两个函数。将所有未被使用过的及已被删除的分量用游标链成一个备用的链表,每当进行插入操作时,就从备用链表上取得第一个结点作为待插入的新结点。实现代码如下:
/*删除在L中第i个元素*/
Status ListDelete(StaticLinkList L,int i){
int j,k; //j为计数器
k=MAX_SIZE-1; //k是最后一个元素的下标
if(i<1||i>ListLength(L)) return ERROR; //删除位置错误
for(j=1;j<=i-1;j++) k=L[k].cur; //查找第i-1个元素
j=L[k].cur; //j是第i个元素的下标
L[k].cur=L[j].cur; //删除第i个元素,将第i-1个元素的游标指向第i+1个
Free_SLL(L,i); //释放第i个元素(回收到备用链表)
return OK;
}
/*将下标为k的空闲节点会受到备用链表,相当于将k结点头插入备用链表*/
void Free_SLL(StaticLinkList space,int k){
space[k].cur=space[0].cur;
space[0].cur=k;
}
/*初始条件:静态链表L已经存在;操作结果:返回L中数据元素个数*/
int ListLength(StaticLinkList L){
int j=0;
int i=MAX_SIZE-1; //i是最后一个元素的下标
while(i){ //遍历到最后一个元素
i=L[i].cur;
j++;
}
return j;
}
小结
总结下静态链表的优缺点,如下所示:
优点 | 缺点 |
---|---|
在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进了在顺序存储结构中的插入和删除操作需要移动大量元素的缺点 | 没有解决连续存储分配带来的表长难以确定的问题 |
失去了顺序存储结构随机存取的特性 |
结论:
静态链表是为了给没有指针的高级语言设计的一种实现单链表能力的方法,可以掌握这样的思考方式。
循环链表
将单链表中终端节点的指针端由空指针改为指向头节点,就使整个单链表形成一个环,这种头尾相接的单链表成为单循环链表,简称循环链表(Circular Linked List)。
循环链表和单链表的主要差异在于循环的判断条件上,原来是判断p->next是否为空,现在则是p->next不等于头结点,则循环结束。
可以改造循环链表,不用头指针,用指向终端结点的尾指针来表示循环链表,这样查找头结点和终端结点的时间复杂度均为 O(1) ,就很简单了。
eg:将两个循环列表合并成一个循环列表,用尾指针就很简单了:
p=rearA->next; //保存表A的头结点
rearA->next=rearB->next->next; //将A的末尾连接B的第一结点
q=rearB->next;
rearB->next=p; //将表B的末尾和表A的头结点连接
free(q);
双向链表
双向链表(Double Linked List)是在单链表的每个节点中,在设置一个指向其前驱结点的指针域。所以双向链表有两个指针域,一个指向直接后继,一个指向直接前驱。
/*线性表的双向链表存储结构*/
typedef struct DulNode{
ElemType data;
struct DulNode *prior; //直接前驱指针
struct DulNode *next; //直接后继指针
}DulNode,*DuLinkList;
双向链表可以反向遍历查找,但是在插入和删除操作时,需要更改两个指针变量。(以空间换时间)
插入操作(顺序很重要)–结点s插入到p,q之间
s->prior=p; //将p赋值给s的前驱
s->next=p->next; //将p->next赋值给s的后继
p->next->prior=s; //将s赋值给p的后继的前驱
p->next=s; //将p的后继赋值为s,最后,因为前面两步使用了p->next
删除操作–删除结点p
p->prior->next=p->next;
p->next->prior=p->prior;
free(p);