线性表
1、 线性表的定义
线性表是具有相同数据类型的n(n≥0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为 L = ( a 1 , a 2 , . . . , a i , . . . , a n ) L=(a_1,a_2, ... ,a_i, ... ,a_n) L=(a1,a2,...,ai,...,an)
解释:
- a i a_i ai是线性表中的“第i个”元素线性表中的
位序
- a 1 a_1 a1是
表头元素
; a n a_n an是表尾元素
- 除第一个元素外,每个元素有且仅有一个
直接前驱
;除最后一个元素外,每个元素有且仅有一个直接后继
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TNeXFSAd-1642004419324)(C:\Users\zc262310\Desktop\数据结构\img\image-20220101010705689.png)]
2、线性表的基本操作
- InitList(&L):初始化表。构造一个空的线性表L,分配内存空间
- DestroyList(&L):销毁操作。销毁线性表,并释放 线性表L所占用的内存空间
- ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e
- ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
- LocateElem(L,e):按值查找操作。在表L中查找具体给定关键字值的元素。
- GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。
- Length(L):求表长。返回线性表L的长度,即L中数据元素的个数
- PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。
- Empty(L):判空操作。若L为空表,则返回true,否则返回flase。
Tips:
- 对数据的操作(记忆思路)——创销、增删改查
- C语言函数的定义——<返回值类型> 函数名(<参数1类型> 参数1,<参数2类型> 参数2,…)
- 实际开发中,可根据实际需求定义其他的基本操作
- 函数名和参数的形式、命名都可以改变(这里参考:严蔚敏版《数据结构》)
- 什么时候要传入参数的引用“&”——对参数的修改结果需要“带回来” 。
“&”表示C++语言中的引用调用,在C语言中采用指针也可以达到同样的效果。
3、线性表的顺序表示
注意点:
- ElemType表示的是某个元素类型,它可以是int、char、float、double等等,也可以是结构体。
- 下面代码都是基于C++的伪代码,不能独立运行。
- 下面的具体某些操作的伪代码并不代表唯一的写法,但基本思路是一样的。
3.1、顺序表的定义
顺序表——用顺序存储的方式实现线性表。
顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来实现。
3.2、顺序表的实现
- 顺序表最主要的特点是随机访问,即通过首地址和元素序号可在时间O(1)内找到指定的元素。
- 顺序表的储存密度高,每个结点只存储数据元素。
- 顺序表逻辑上相邻的元素物理上也相邻,所以插入和删除操作需要移动大量元素。
3.2.1、静态分配
#define MaxSize 10 //定义最大长度
typedef struct{
ElemType data[MaxSize]; //用静态的"数组"存放数据元素
int length; //顺序表的当前长度
}SeqList; //顺序表的类型定义
在静态分配时,由于数组的大小和空间实现已经固定,一旦空间占满,在加入新的数据就会产生溢出,进而导致程序崩溃。
3.2.2、动态分配
#define InitSize 10 //默认的最大长度
typedef struct{
ElemType *data; //指示动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SeqList; //顺序表的类型定义
key:动态申请和释放内存空间
1、C——malloc、free函数
//malloc调用格式
(类型说明符*)malloc(size)
功能:在内存的动态存储区中分配一块长度为"size"字节的连续区域。函数的返回值为该区域的首地址。
-
“类型说明符”表示把该区域用于何种数据类型。
-
(类型说明符*)表示把返回值强制转换为该类型指针。
-
“size”是一个无符号数。
//free调用格式
free(void*ptr);
功能:释放ptr所指向的一块内存空间,ptr是一个任意类型的指针变量,它指向被释放区域的首地址。被释放区应是由malloc或calloc函数所分配的区域。
malloc、free函数的头文件为 #include <stdlib.h>
2、C++——new、delete关键字
//new调用格式
格式1:指针变量名=new 类型标识符;
例如:int *a=new int; //开辟一个存放整数的存储空间,返回一个指向该存储空间的地址。
格式2:指针变量名=new 类型标识符(初始值);
例如:int *a=new int(a); //开辟一个存放整数的存储空间,返回一个指向该存储空间的地址,但是同时将整数空间赋值为5。
格式3:指针变量名=new 类型标识符 [内存单元个数];
例如: int *a = new int[100]; //开辟一个大小为100的整型数组空间
使用new运算符时必须已知数据类型,new运算符会向系统堆区申请足够的存储空间,如果申请成功,就返回该内存块的首地址,如果申请不成功,则返回零值。
//delete调用格式
delete 指针变量名;
例如: int *a = new int;delete a; //释放int指针的空间
delete删除地址空间
动态分配具体实现代码
#define InitSize 10 //默认的最大长度
typedef struct{
int *data; //指示动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SeqList; //顺序表的类型定义
//初始化动态分配的顺序表
void InitList(SeqList &L){
//用malloc函数申请一片连续的存储空间
L.data=(int *)malloc(InitSize*sizeof(int));
L.length=0;
L.MaxSize=InitSize;
}
//增加动态空间的大小
void IncreaseSize(SeqList &L,int len){
int *p=L.data;
L.data=(int *)malloc((L.MaxSize+len)*sizeof(int));
for(int i=0;i<L.length;i++){
L.data[i]=p[i]; //将数据复制到新区域
}
L.MaxSize=L.MaxSize+len; //顺序表最大长度增加len
free(p); //释放原来的内存空间
}
int main(){
SeqList L; //声明一个顺序表
InitList(L); //初始化顺序表
//....此处省略一些代码,插入几个元素
IncreaseSize(L,5);
return 0;
}
3.3、顺序表上基本操作的实现
(1)插入操作
#define MaxSize 10 //定义最大长度
typedef struct{
ElemType data[MaxSize]; //用静态的"数组"存放数据元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义
//-----------------插入操作核心代码-----------------------
void ListInsert(SqList &L,int i,ElemType e){
for(int j=L.length;j>=i;j--){
//将第i个元素及之后的元素后移
L.data[j]=L.data[j-1];
}
L.data[i-1]=e; //在位置i处放入e
L.length++; //长度加1
}
//-----------------插入操作核心代码-----------------------
int main(){
SqList L; //声明一个顺序表
InitList(L); //初始化顺序表
//....此处省略一些代码,插入几个元素
ListInsert(L,3,3);
return 0;
}
例如:ListInsert(L,9,3);插入的位置不合法,代码的健壮性不高。对代码进行修改。
bool ListInsert(SqList &L,int i,ElemType e){
if(i<1||L.length+1) //判断i的范围是否有效
return false;
if(L.length>=MaxSize) //当前储存空间已满,不能插入
return false;
for(int j=L.length;j>=i;j--){
//将第i个元素及之后的元素后移
L.data[j]=L.data[j-1];
}
L.data[i-1]=e; //在位置i处放入e
L.length++; //长度加1
return ture;
}
(2)删除操作
//-----------------删除操作核心代码-----------------------
bool ListDelete(SeqList &L,int i,ElemType &e){
if(i<1||i>L.length) //判断i的范围是否有效
return false;
e=L.data[i-1];
for(int j=i;j<L.length;j++){
L.data[j-1]=L.data[j];
}
L.length--;
return true;
}
//-----------------删除操作核心代码-----------------------
int main(){
SeqList L; //声明一个顺序表
InitList(L); //初始化顺序表
//...此处省略一些代码,插入几个元素
int e=-1; //用变量e把删除的元素"带回来"
if(ListDelete(L,3,e))
printf("已成功删除%d",e);
else
printf("位序不合法,删除失败");
return 0;
}
(3)查找操作
-
按位查找
//返回查找到的元素 ElemType GetElem(SeqList L,int i){ return L.data[i-1]; }
-
按值查找
//返回查找到元素的次序 int LocateElem(SeqList L,ElemType e){ for(int i=0;i<L.length;i++){ if(L.data[i]==e) return i+1; } return 0; }
3.3、顺序表的代码实现
4、线性表的链式表示
4.1、单链表
4.1.1、单链表的定义
线性表的链式存储又称单链表
,它是指通过一组任意的存储单元来存储线性表中的数据元素。为了建立数据元素之间的线性关系,对每个链表结点,除存放元素自身的信息外,还需要存放一个指向其后继的指针
单链表中结点类型的具体描述
typedef struct LNode{
//定义单链表结点类型
ElemType data; //数据域
struct LNode *next; //指针域
}LNode,*LinkList;
typedef关键字
——数据类型重命名
//typedef用法:typedef <数据类型> <别名>
struct LNode{
ElemType data;
struct LNode *next;
};
typedef struct LNode LNode; //把 struct LNode 重命名LNode
typedef struct LNode *LinkList; //把 struct LNode* 重命名为LinkList
//------------上述简便写法-------------------
typedef struct LNode{
//定义单链表结点类型
ElemType data; //数据域
struct LNode *next; //指针域
}LNode,*LinkList;
//------------上述简便写法-------------------
注意:
使用LinkList强调这是一个单链表
使用LNode *强调这是一个结点
4.1.2、头指针和头结点
定义
头指针:是指链表指向第一个结点的指针,通常用头指针表示一个一个单链表。
头结点:为了操作上的方便,在单链表第一个结点之前附加一个结点,可以加也可以不加。
区别
不管带不带头结点,头指针都始终指向链表的第一个结点,而头结点是带头结点的链表中的第一个结点,结点内通常不存储信息。
头结点优点
①由于第一个数据结点的位置被存放在头结点的指针域中,因此在链表的第一个位置上的操作和在表上的其他位置上的操作一致,无须进行特殊处理。
②无论链表是否为空,其头指针都指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就得到了统一。
不带头结点的单链表
typedef struct LNode{
//定义单链表结点类型
ElemType data; //每个结点存放一个数据元素
struct LNode *next; //指针指向下一个结点
}LNode,*LinkList;
//初始化一个空的单链表
bool InitList(LinkList &L){
L=NULL; //空表,暂时还没有任何结点。防止脏数据
return true;
}
int main(){
LinkList L; //声明一个指向单链表的指针
//初始化一个空表
InitList(L);
}
带头结点的单链表
typedef struct LNode{
//定义单链表结点类型
ElemType data; //每个结点存放一个数据元素
struct LNode *next; //指针指向下一个结点
}LNode,*LinkList;
//初始化一个空的单链表
bool InitList(LinkList &L){
L=(LNode *)malloc(sizeof(LNode));//分配一个头结点
if(L==NULL) //内存不足,分配失败
return false;
L->next=NULL; //头结点之后暂时还没有结点
return ture;
}
int main(){
LinkList L; //声明一个指向单链表的指针
//初始化一个空表
InitList(L);
}
说明:带头结点和不带头结点的区别在于带头结点的在初始化时需要先分配一个头结点。
4.1.3、单链表上的基本操作的实现
1、插入操作
1、按位序插入(带头结点)
//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L,int i,ElemType e){
LNode *p; //指针p指向当前扫描到的结点
int j=0; //当前p指向的第几个结点
p=L; //L指向头结点,头结点是第0个结点(不存数据)
while(p&&j<i-1){
//循环找到第i-1个结点
p=p-> next;
j++;
}
if(!p||j>i-1) //i值不合法(i>n+1或者i<1)
return false;
LNode *s=(LNode *)malloc(sizeof(LNode));//生成新结点*s
s-data=e; //将结点*s的数据域置为e
s->next=p->next; //语句一
p->next=s; //语句二
return ture; //插入成功
}
注意:
语句一和语句二的顺序不能改变。假如先执行p->next=s
再执行s->next=p->next
,就会发下s->next指向自己这个结点。
2、按位序插入(不带头结点)
//在第i个位置插入元素e(不带头结点)
bool ListInsert(LinkList &L,int i,ElemType e){
if(i==1){
LNode *s=(LNode *)malloc(sizeof(LNode));
s->data=e;
s->next=L;
L=s; //头指针指向新结点
return ture;
}
LNode *p; //指针p指向当前扫描到的结点
int j=1; //当前p指向的第几个结点
p=L; //p指向第一个结点(注意:不是头结点)
while(p&&j<i-1){
//循环找到第i-1个结点
p=p-next;
j++;
}
if(!p||j>i-1) //i值不合法(i>n+1或者i<1)
return false;
LNode *s=(LNode *)malloc(sizeof(LNode));//生成新结点*s
s-data=e; //将结点*s的数据域置为e
s->next=p->next;
p->next=s;
return ture;
}
说明:如果不带头结点,则插入、删除第一个元素时,需要修改头指针L。
3、指定结点的后插操作
//后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p,ElemType e){
if(p==NUll)
return false;
LNode *s=(LNode *)malloc(sizeof(LNode));//生成新结点*s
if(s==NUll) //内存分配失败
return false;
s->data=e; //将结点*s的数据域置为e
s->next=p->next;
p->next=s;
return ture; //插入成功
}
4、指定结点的前插操作
//前插操作:在p结点之前插入元素e
bool InsertNextNode(LNode *p,ElemType e){
if(p==NUll)
return false;
LNode *s=(LNode *)malloc(sizeof(LNode));//生成新结点*s
if(s==NUll) //内存分配失败
return false;
s->next=p->next;
p->next=s; //新结点 s 连到 p 之后
s->data=p->data; //将p中元素复制到s中
p->data=e; //p中元素覆盖为e
return ture; //插入成功
}
解释:因为找不到p结点之前的结点,所以新建s结点只能插到p结点之后。然后再将两个结点的数据进行交换,变相 相当于插入到p结点之前。
2、删除操作
1、按位序删除(带头结点)
bool ListDelete(LinkList &L,int i,ElemType &e){
LNode *p;
int j=0;
p=L;
while((p->next)&&(j<i-1)){
//查找第i-1个结点,p指向该结点
p=p->next;
j++;
}
if(!(p->next)||(j>i-1))//i>n或i<1时,删除位置不合理
return false;
LNode *q=p->next; //临时保存被删除的地址以备释放
e=q->data;
p->next=q->next; //改变删除结点前驱结点的指针域
free(q) //释放结点的存储空间
}
说明
删除算法中的循环条件**(p->next&&j<i-1)和插入算法中的循环条件(p&&(j<i—1))**是有所区别的。因为插入操作中合法的插入位置有n+1个,而删除操作中合法的删除位置只有n个,如果使用与插入操作相同的循环条件,则会出现引用空指针的情况,使删除操作失败。
2、指定结点的删除
bool DeleteNode(LNode *p){
if(p==NUll)
return false;
LNode *q=p->next; //令q指向*p的后继结点
p->data=p->next->data; //和后继结点交换数据域
p->next=q->next; //将*q结点从链中"断开"
free(q); //释放后继结点的存储空间
return ture;
}
如果是p最后一个结点,只能从表头开始依次寻找p的前驱,时间复杂度O(n)
3、查找操作
谈论的都是带头结点的情况
1、按位查找
//按位查找,返回第i个元素(带头结点)
LNode * GetElem(LinkList L,int i){
if(i<0)
return NULL;
LNode *p; //指针p指向当前扫描到的结点
int j=0; //当前p指向的是第几个结点
p=L; //L指向头结点,头结点是第0个结点(不存数据)
while(p!=NULL&&j<i){
//循环找到第i个结点
p=p->next;
j++;
}
return p;
}
2、按值查找
//按值查找,找到数据域==e的结点(带头结点)
LNode *LocateElem(LinkList L,ElemType e){
LNode *p=L->next;
//从第1个结点开始查找数据为e的结点
while(p&&p->data!=e)
p=p->next;
return p; //找到后返回该结点的指针,否则返回NULL
}
4、单链表的建立
1、尾插法
void CReateLIst_R(LinkList &L,int n){
//正位序输入n个元素的值,建立带表头结点的单链表L
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL; //先建立一个带头结点的空链表
LNode *p;
LNode *r=L; //尾指针r指向头结点
for(i=0;i<n;i++){
p=(LinkList)malloc(sizeof(LNode));//生成新结点
cin>>p->data; //输入元素值复制给新结点*p的数据域
p->next=NULL; //新插入的结点指针置空
r->next=p; //将新结点*p插入尾结点*r之后
r=p; //r指向新的尾结点*p
}
}
2、头插法
void CreateList_H(LinkList &L,int n){
//逆位序输入n个元素的值,建立带表头结点的单链表L
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL; //先建立一个带头结点的空链表
LNode *p;
for(int i=0;i<n;i++){
p=(LinkList)malloc(sizeof(LNode));//生成新结点*p
cin>>p->data; //输入元素值复制给新结点*p的数据域
p->next=L->next;L->next=p; //将新结点*p插入到头结点之后
}
}
4.1.4、单链表的代码实现
4.2、双链表
4.2.1、双链表的定义
在单链表中,查找直接后继结点的执行时间为O(1),而查找直接前驱的执行时间为O(n)。为克服单链表这种单向性的缺点,可利用双向链表(Double Linked List)。
顾名思义,在双向链表的结点中有两个指针域,一个指向直接后继,另一个指向直接前驱。结点结构如下图所示。
//----在双向链表的存储结构
typedef struct DuLNode{
ElemType data; //数据域
struct DuLNode *prior; //指向直接前驱
struct DuLNode *next; //指向直接后驱
}DuLNode,*DuLinkList;
4.2.2、双链表上的基本操作的实现
1、双链表的初始化
bool InitDLinkList(DLinkList &L){
L=(DuLNode *)malloc(sizeof(DuLNode)); //分配一个头结点
if(L==NULL) //内存不足,分配失败
return false;
L->prior=NULL; //头结点的prior永远指向NULL
L->next=NULL; //头结点之后暂时还没结点
return true;
}
2、双向链表的插入
(1)p结点之前插入
bool ListInsert_DuL(DuLinkList &L,int i,ElemType e){
//在带头结点的双链表L中第i个位置之前插入元素e
DuLNode *p,*s;
if(!(p=GetElem_DuL(L,i))) //在L中确定第i个元素的位置指针p
return false; //p为NULL时,第i个元素不存在
s=(DuLNode *)malloc(sizeof(DuLNode));//生成新结点*s
s->data=e; //将结点*s数据域置为e
s->prior=p->prior; //将结点*s插入L中,此步对应上图①
p->prior->next=s; //对应上图②
s->next=p; //对应上图③
p->prior=s; //对应上图④
return true;
}
注意前后的顺序。
(2)p结点之后插入
//在p结点之后插入s结点
bool InsertNextDuLNode(DuLNode *p,DuLNode *s){
if(p==NULL||s==NULL) //非法参数
return false;
s->next=p->next;
if(p->next!=NULL) //如果p结点有后继结点
p->next->prior=s;
s->prior=p;
p->next=s;
return true;
}
说明:p结点之前和之后插入的主要区别就是,p结点之后插入时需要判断p结点是不是最后的结点。
总结:对于插入操作指针谁指向谁有的人都很不理解,当然也记不住先后顺序。这里有个小技巧(按p结点之前插入的图来看):
(1)找离结点P最远的那一侧指针域,即S结点的前驱。所以有①②(没有先后顺序)操作
(2)因为插入的是S结点,需要主动发起。所以先是①后是②
(3)再看另一侧指针域,即S结点的后驱。
(4)因为插入的是S结点,需要主动发起。所以先是③后是④
3、双向链表的删除
void ListDelete_DuL(DuLinkList &L,int i){
//删除带头结点的双向链表L中的第i个元素
DuLNode *p,*s;
if(!(p=GetElem_DuL(L,i))) //在L中确定第i个元素的位置指针p
return false; //p为NULL时,第i个元素不存在
p->prior->next=p->next; //修改被删结点的前驱结点的后继指针,对应上图①
p->next->prior=p->prior; //修改被删结点的后继结点的前驱指针,对应上图②
free(p); //释放被删结点的空间
}
删除p结点的后继结点
//删除p结点的后继结点
bool DeleteNextDuLNode(DuLNode *p){
if(p==NULL)
return false;
DuLNode *q=p->next; //找到p的后继结点q
if(q==NULL) //p没有后继
return false;
p->next=q->next;
if(q->next!=NULL) //q结点不是最后一个结点
q->next->prior=p;
free(q); //释放结点空间
return true;
}
4.3、循环链表
4.3.1、循环单链表
循环单链表:从一个结点出发可以找到其他任何一个结点
//初始化一个循环单链表
bool InitList(LinkList &L){
L=(LNode *)malloc(sizeof(LNode));//分配一个头结点
if(L==NUll) //内存分配不足,分配失败
return false;
L->next=L; //头结点next指向头结点
return true;
}
4.3.2、循环双链表
//循环双链表初始化
bool InitDuLinkList(DuLinkList &L){
L=(DuLNode *)malloc(sizeof(DuLNode));//分配一个头结点
if(L==NULL) //内存不足,分配失败
return false;
L->prior=L; //头结点的prior指向头结点
L->next=L; //头结点的next指向头结点
return true;
}
双链表的插入
//在p结点之后插入s结点
bool InsertNextDuLNode(DuLNode *p,DuLNode *s){
s->next=p->next;
p->next->prior=s;
s->prior=p;
p->next=s;
return true;
}
不用像单向双链表一样判断p结点是不是最后一个结点。
双链表的删除
//删除p结点的后继结点
bool DeleteNextDuLNode(DuLNode *p){
if(p==NULL)
return false;
DuLNode *q=p->next; //找到p的后继结点q
p->next=q->next;
q->next->prior=p;
free(q); //释放结点空间
return true;
}
不用像单向双链表一样判断p有没有后继和p的后继结点是不是最后一个结点
4.4、静态链表
静态链表:分配一整片连续的内存空间,各个结点集中安置。
静态链表的定义
#define MaxSize 10 //静态链表的最大长度
struct Node{
//静态链表结构类型的定义
ElemType data; //存储数据元素
int next; //下一个元素的数组下标
};
void testSLinkList(){
struct Node a[MaxSize];
//数组a作为静态链表
}
//另一种等价写法
struct Node{
//静态链表结构类型的定义
ElemType data; //存储数据元素
int next; //下一个元素的数组下标
}SLinkList[MaxSize];
void testSLinkList(){
SLinkList a ;
//数组a作为静态链表
}
总结:
静态链表:用数组的方式实现的链表。
优点:增删操作不需要大量移动元素。
缺点:不能随机存取,只能从头结点开始依次往后查找;容量固定不变。
适应场景:①不支持指针的低级语言。②数据元素数量固定不变的场景。
4.5、顺序表和链表的比较
4.5.1、空间性能的比较
(1)存储空间的分配
顺序表:存储空间必须预先分配,元素个数扩充受到一定限制,易造成存储空间的浪费或空间溢出现象。
链表:不需要为其预先分配空间,只要内存允许,链表中的元素个数就没有限制。
(2)存储密度的大小
在这里插入图片描述
顺序表的存储密度为1,而链表的存储密度小于1。如果每个元素数据域占据的空间较小,则指针的结构性开销就占用了整个结点的大部分空间,这样存储密度较小。
4.5.2、时间性能的比较
(1)存取元素的效率
顺序表是由数组实现的,它是一种随机存取结构,指定任意一个位置序号i,都可以在O(1)时间内直接存取该位置上的元素,即取值操作的效率高。
链表是一种顺序存取结构,按位置访问链表中第i个元素时,只能从表头开始依次向后遍历链表,直到找到第i个位置上的元素,时间复杂度为O(n),即取值操作的效率低。
(2)插入和删除操作的效率
链表在确定插入或删除的位置后,插入或删除操作无需移动数据,只需要修改指针,时间复杂度为O(1)。
顺序表进行插入或删除时,平均要移动表中近一半的结点,时间复杂度为O(n)。尤其是当每个结点的信息量较大时,移动结点的时间开销就相当可观。