2.1_线性表的定义和基本操作
线性表是具有相同数据类型的n(n>=0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为
各个数据元素所占空间一样大
ai是线性表中的第“第i个“元素线性表中的位序
a1是表头元素;an是表尾元素。
除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继
注意:位序从1开始,数组下标从0开始
线性表的基本操作:
InitList(&L):初始化表。构造一个空的线性表L,分配内存空间。
DestoryList(&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,否则返回false。
Tips:
对数据的操作(记忆思路)——创销、增删改查
C语言函数的定义—— <返回值类型> 函数名 (<参数1类型> 参数1,<参数2类型> 参数2,......)
实际开发中,可根据实际需求定义其他的基本操作
函数名和参数的形式、命名都可改变(Key:命名要有可读性)
什么时候要传入引用&——对参数的修改结果需要“带回来“
为什么要实现对数据结构的基本操作?
- 团队合作编程,你定义的数据结构要让别人能够很方便的使用(封装)
- 将常用的操作/运算封装成函数,避免重复工作,降低出错风险
2.2.1_顺序表的定义
顺序表——用顺序存储的方式实现线性表
顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
如何知道一个数据元素大小?
C语言sizeof(ElemType)
ElemType就是你的顺序表中存放的数据元素类型
顺序表的实现——静态分配
#difine MaxSize 10 //定义最大长度
typedef struct{
ElemType data[MaxSize]; //用静态的“数组“存放数据元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义(静态分配方式)
给各个数据元素分配连续的存储空间,大小为MaxSize*sizeof(ElemType)
顺序表的实现——动态分配
#define InitSize 10 //顺序表的初始长度
typedef struct{
ElemType *data; //指示动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SeqList; //顺序表的类型定义(动态分配方式)
Key:动态申请和释放内存空间
C——malloc、free函数
顺序表的特点:
- 随机访问,即可以在O(1)时间内找到第i个元素
- 存储密度高,每个节点只存储数据元素
- 扩展容量不方便(即便采用动态分配的方式实现,扩展长度的时间复杂度也比较高)
- 插入、删除操作不方便,需要移动大量元素
静态分配:
使用“静态数组”实现
大小一旦确定就无法改变
动态分配:
L.data = (ElemType *) malloc (sizeof(ElemType) * size);
顺序表存满时,可再用malloc动态扩展顺序表的最大容量
需要将数据元素复制到新的存储区域,并用free函数释放原区域
2.2.2_1_顺序表的插入删除
ListInsert(&L,i,e):插入操作:在表L中第i个位置上插入指定元素e。
注:本节代码建立在顺序表的“静态分配”实现方式上,“动态分配”也雷同。
最好情况:新元素插入到表尾,不需要移动元素,i=n+1,循环0次;最好时间复杂度=O(1)
最坏情况:新元素插入到表头,需要将原有的n个元素全部向后移动,i=1,循环n次;最坏时间复杂度=O(n);
平均情况:假设新元素插入到任何一个位置的概率相同,即i=1,2,3,…,length+1的概率都是p=1/(n+1),i=1,循环n次;i=2,循环n-1次;… i=n+1,循环0次,平均循环次数=2/n;平均时间复杂度=O(n)
ListDelete(&L,I,&e):删除操作:删除表L 中第i个位置的元素,并用e返回删除元素的值。
最好情况:删除表尾元素,不需要移动其他元素,i=n,循环0次,最好时间复杂度=O(1)
最坏情况:删除表头元素,需要将后续的n-1个元素全都向前移动,i=1,循环n-1次;最坏时间复杂度=O(n);
平均情况:平均时间复杂度=O(n)
代码要点:
代码中注意位序i和数组下标的区别
算法要有健壮性,注意判断i的合法性
分析代码,理解为什么有的参数需要加“&”引用
2.2.2_2_顺序表的查找
顺序表的按位查找
GetElem(L,i):按位查找操作:获取表L中第i个位置的元素的值
获取表L中第i个位置的元素的值
用数组下标即可得到第i个元素L.data[i-1]
ElemType GetElem(SqList L, int i){
Return L.data[i-1];
}
时间复杂度:O(1),最好/最坏/平均时间复杂度都是O(1)
由于顺序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第i个元素——“随机存取”特性
顺序表的按值查找
LocateElem(L,e):按值查找操作:在表L中查找具有给定关键字值的元素。
在顺序表L中第一个元素值等于e的元素,并返回其位序
从第一个元素开始依次往后检索
注意:C语言中,结构体的比较不能直接用“==”
需要依次对比各个分量来判断两个结构体是否相等
最好情况:目标元素在表头,循环一次;最好时间复杂度=O(1)
最坏情况:目标元素在表尾,循环n次;最坏时间复杂度=O(n)
平均情况:假设目标元素出现在任何一个位置的概率相同,都是1/n。平均时间复杂度=O(n)
2.3.1_单链表的定义
每个结点除了存放数据元素外,还要存储指向下一个节点的指针
优点:不要求大片连续空间,改变容量方便
缺点:不可随机存取,要耗费一定空间存放指针
struct LNode{ //定义单链表结点类型
ElemType data; //每个节点存放一个数据元素
struct LNode *next; //指针指向下一节点
};
struct LNode * p = (struct LNode *) malloc(sizeof(struct LNode));
增加一个新的结点:再内存中申请一个结点所需空间,并用指针p指向这个结点
typedef关键字——数据类型重命名
typedef <数据类型> <别名>
typedef int zhengshu;
typedef int *zhengshuzhizhen;
定义完后可以用这样的方式来定义:
zhengshu x = 1;
zhengshuzhizhen p;
typedef struct LNode LNode;
LNode * p = (LNode *) malloc(sizeof(LNode));
更简便的方法:
typedef struct LNode{ //定义单链表结点类型
ElemType data; //每个结点存放一个数据元素
struct LNode *next; //指针指向下一个节点
}LNode, *LinkList;
要表示一个单链表时,只需声明一个头指针L,指向单链表的第一个结点
LNode * L; //声明一个指向单链表第一个结点的指针
或:LinkList L; //声明一个指向单链表第一个结点的指针
强调这是一个单链表——使用LinkList
强调这是一个结点——使用LNode*
不带头结点,写代码更麻烦
对第一个数据结点和后续数据结点的处理需要用不同的代码逻辑
对空表和非空表的处理需要用不同的代码逻辑
单链表:
用“链式存储“(存储结构),实现了”线性结构“(逻辑结构)
一个结点存储一个数据元素
各结点间的先后关系用一个指针表示
两种实现:
不带头结点
空表判断:L == NULL。写代码不方便
带头结点
空表判断:L -> next == NULL。写代码更方便
头结点不存数据,只是为了操作方便
2.3.2_1_单链表的插入和删除
LinstInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。
找到第i-1个结点,将新结点插入其后
头结点可以看作“第0个“结点
如果不带头结点,则插入、删除第1个元素时,需要更改头指针L
结论:不带头结点写代码更不方便
考试中带头、不带头都有可能考察
ListDelete(&L,i,&e):删除操作:删除表L中第i个位置的元素,并用e返回删除元素的值。
找到第i-1个结点,将其指针指向第i+1个结点,并释放第i个结点
最坏、平均时间复杂度:O(n)
最好时间负责度:O(1)
指定结点的删除:
如果p是最后一个结点,只能从表头开始依次寻找p的前驱,时间复杂度O(n)
单链表的局限性:无法逆向检索,有时候不太方便
2.3.2_2_单链表的查找
GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。
LoctaeElem(L,e):按值查找操作。在表L中查找具有给定关键字值得元素。
按位查找平均时间复杂度:O(n)
按值查找平均时间复杂度:O(n)
单链表不具备“随机访问“的特性,只能依次扫描
三种基本操作(按位查找、按值查找、求单链表长度)的时间复杂度都是O(n)
注意边界条件的处理
2.3.2_3_单链表的建立
单链表的建立:
尾插法、头插法
如果给你很多个数据元素(ElemType),要把它们存到一个单链表里,如何处理
Step 1:初始化一个单链表
Step 2:每次取一个数据元素,插入到表尾/表头
设置变量length记录链表长度
While循环{
每次取一个数据元素e;
ListInsert(L,length+1,e)插到尾部;
length++;
}
设置一个表尾指针
头插法建立单链表:
初始化单链表
While循环{
每次取一个数据元素e;
InsertNextNode(L,e);
}
只要是初始化单链表,都先把头指针指向NULL
头插法的重要应用:链表的逆置
头插法、尾插法:核心就是初始化操作、指定结点的后插操作
2.3.3_双链表
单链表:无法逆向检索,有时候不太方便
双链表:可进可退,存储密度更低一点
typedef struct DNode{ //定义双链表结点类型
ElemType Data; //数据域
struct DNode *prior, *next; //前驱和后继指针
}DNode, *DLinklist;
bool InitDLinkList(DLinklist &L){
L=(DNode *) malloc(sizeof(DNode)); //分配一个头结点
if (L==NULL) //内存不足,分配失败
return false;
L->prior = NULL; //头结点的prior永远指向NULL
L->next = NULL; //头结点之后暂时还没有结点
return true;
}
void testDLinkList() {
//初始化双链表
DLinklist L;
InitDLinkList(L);
//后续代码
}
双链表的插入
//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s){
s->next=p->next; // 将结点*s插入到结点*p之后
p->next->prior=s;
s->prior=p;
p->next=s;
}
改进后(如果插入位置是最后一个结点)
bool InsertNextDNode(DNode *p, DNode *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的后继结点q
p->next=q->next;
q->next->prior=p;
free(q);
增加条件判断改进
销毁一个双链表:
void DestoryList(DLinklist &L){
//循环释放各个数据结点
while (L->next != NULL)
DeleteNextDNode(L);
free(L); //释放头结点
L=NULL; //头指针指向NULL
}
双链表的遍历:
后向遍历
while(p!=NULL)
//对结点p做相应处理,如打印
p=p->next;
}
前向遍历:
while(p!=NULL)
//对结点p做相应处理
p=p->prior
}
双链表不可随机存取,按位查找、按值查找操作都只能用遍历的方式实现。时间复杂度O(n)
初始化:头结点的prior、next都指向NULL
插入(后插):
注意新插入结点、前驱结点、后继结点的指针修改
边界情况:新插入结点在最后一个位置,需特殊处理
删除(后删):
注意删除结点的前驱结点、后继结点的指针修改
边界情况:如果被删除结点是最后一个数据结点,需特殊处理
遍历:
从一个给定结点开始,后向遍历、前向遍历的实现(循环的终止条件)
链表不具备随机存取特性,查找操作只能通过顺序遍历实现
2.3.4_循环链表
单单链表:表尾结点的next指针指向NULL
循环单链表:表尾结点的next指针指向头结点
//判断结点p是否为循环单链表的表尾结点
bool isTail(LinkList L, LNode *p){
if(p->next == L)
return true;
else
return false;
}
单链表:从一个结点出发只能找到后续的各个结点
循环单链表:从一个结点出发可以找到其他任何一个结点
从头结点找到尾部,时间复杂度为O(n)
当对链表的操作都是在头部或尾部时,可以让L指向表尾元素(插入、删除时可能需要修改L)
这样从尾部找到头部,时间复杂度为O(1)
双链表:
表头结点的prior指向NULL
表尾结点的next指向NULL
循环双链表:
表头结点的prior指向表尾结点
表尾结点的next指向头结点
循环双链表的初始化
L->prior = L; //头结点的prior指针指向头结点
L->next = L; //头结点的next指向头结点
//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s){
s->next=p->next; //将结点*s插入到结点*p之后
p->next->prior=s;
s->prior=p;
p->next=s;
}
2.3.5_静态链表
单链表:各个结点在内存中星罗棋布、散落天涯。
静态链表:分配一整片连续的内存空间,各个结点集中安置。
0号结点充当“头结点“
游标充当“指针“
游标为-1表示已经到达表尾
#define MaxSize 10 //静态链表的最大长度
struct Node{ //静态链表结构类型的定义
ElemType data; //存储数据元素
int next; //下一个元素的数组下标
};
void testSLinkList(){
struct Node a[MaxSize];
//后续代码
}
初始化静态链表:
把a[0]的next设为-1
查找:
从头结点出发挨个往后遍历结点
插入位序为i的结点:
- 找到一个空的结点,存入数据元素
- 从头结点出发找到位序为i-1的结点
- 修改新结点的next
- 修改i-1号结点的next
可让空闲结点next为某个特殊值用来表示结点空闲,如-2
删除某个结点:
- 从头结点出发找到前驱结点
- 修改前驱结点的游标
- 被删除结点next设为-2
静态链表:用数组的方式实现的链表
优点:增、删操作不需要大量移动元素
缺点:不能随机存取,只能从头结点开始依次往后查找:容量固定不可变
适用场景:不支持指针的低级语言;数据元素数量固定不变的场景(如操作系统的文件分配表FAT)
2.3.6_顺序表和链表的比较
逻辑结构:
都属于线性表,都是线性结构
存储结构:
顺序表优点支持随机存取、存储密度高;缺点大片连续空间分配不方便,改变容量不方便。
链表优点离散的小空间分配方便,改变容量方便;缺点不可随机存取,存储密度低。
基本操作:
创:
顺序表:需要预分配大片连续空间。若分配空间过小,则之后不方便扩展容量;若分配空间过大,则浪费内存资源
链表:只需分配一个头结点(也可以不要头结点,只声明一个头指针),之后方便扩展
销:
顺序表:修改Length=0;静态分配实现系统自动回收空间,动态分配需要手动free
链表:依次删除各个结点(free)
增、删:
顺序表:插入/删除元素要将后续元素都后移/前移,时间复杂度O(n),时间开销主要来自移动元素(若数据元素很大,则移动的时间代价很高)
链表:插入/删除只需修改指针即可,时间复杂度为O(n),时间开销主要来自查找目标元素(查找元素的时间代价更低)
查:
顺序表:按位查找:O(1);按值查找:O(n)
链表:按位查找O(n);按值查找O(n)
用顺序表还是链表?
表长难以预估、经常要增加/删除元素——链表
表长可预估、查询(搜索)操作较多——顺序表
问题:请描述顺序表和链表的……
答:
实现线性表时,用顺序表还是链表好?
顺序表和链表的逻辑结构都是线性结构,都属于线性表
但是二者的存储结构不同,顺序表采用顺序存储…(特点,带来的优点缺点);链表采用链式存储…(特点、导致的优缺点)
由于采用不同的存储方式实现,因此基本操作的实现效率也不同。当初始化时…;当插入一个数据元素时…;当删除一个数据元素时…;当查找一个数据元素时…