1.基本介绍
在前文绪论部分,我们已经介绍过线性结构(Linear),其属于逻辑结构中的一种。其特点为一对一,有一个头和一个尾,优点为可以直接通过索引来访问元素,缺点是在插入和删除元素时要移动的元素数量可能会很多,效率较低 。
今天要学习的是线性结构最为直接的应用:线性表(Linear Lists)
什么是线性表
一系列元素组成的有限序列
例如:(1,2,3,4,5),(A,B,C,D,E,F);
特点: 线性表中的每个元素都必须是相同的类型
为什么线性表中的类型都要是相同的?
实际上,我认为这与它的存储方式有关,前文我们提到的数据元素有顺序存储和链式存储两种存储结构。
对于顺序存储,就是用数组来存储,而数组中的每个元素类型都需要是相同的,每个元素在内存中占的字节数相同,这样就可以通过首元素地址去计算后续每个元素的相应地址;
对于链式存储,需要通过指针将零零散散的地址联系在一起,那我们知道,指针变量的类型必须与它指向的变量的类型相同,一如果每个元素类型都不相同,那就会变得相当复杂。
同时,线性表中的所有元素类型一致,使定义操作算法时更加简洁和一致(删除,查找,插入都依赖于对元素类型的统一假设)
2.线性表的顺序表示
即将线性表中的所有元素都存在一个数组中,特点是 在逻辑上相邻,物理存储位置也相邻
我们可以通过首元素的地址访问其他元素
例如:
所以我们可以通过计算元素的地址对元素进行随机访问(Random Access)
那我们管理一张线性表,需要知道哪些信息呢?
管理一个图书馆,需要知道每本书的位置,馆内一共有多少本书,还能放进来多少本书
管理线性表,需要知道首元素的地址,现在有多少个元素(表长),以及一共能存进来多少个元素(容量)
定义线性表
我们先假设存放的数据类型都为int(可以根据需要自行修改)
#define INITSIZE 100
typedef struct{
int*data; //存放首元素地址
int length; //当前元素个数 表长
int maxSize; //容量
}sqlist; //sequentialList
初始化线性表
void initList(sqList* L){ //传入一个空表地址
L->data = (int*)malloc(INITSIZE * sizeof(int));
//注意容错 申请空间失败
if(!L->data){
printf("Overflow!");
exit(1);
}
L->length = 0; //没有元素,表长为0
L->maxSize = INITSIZE; //申请了INITSIZE个空间
初始化完毕后,我们就会得到这样一张线性表:
创建线性表
就是往线性表里面填充元素
比较关键的步骤就是:
L->data[L->length ++] ;
每添加一个元素之前,表中都是L->length-1个元素,所以下一个元素的下标就为L->length,每填充一个元素,length相应就加1;
当然要注意考虑容错,就是线性表容量不够的情况
void createList(sqList*L){
int x ; //用来存放即将被填充进线性表的元素
//如果线性表还没有被初始化过
initList(L);
scanf("%d",&x); //读取元素
while(x!= -999){ //自己设一个读取结束的条件
//先检查容量够不够
if(L->length == L->maxSize){ //如果容量已经满了
L->data = realloc(L->data,(L->maxSize+1) * sizeof(int)); //再多申请一个空间
// 可以根据需要多申请几个
//...省略申请空间失败的容错检查
L->maxSize ++;
}
L->data[L->length ++];
scanf("%d",&x);
}
这样 我们就可以得到一张有数据元素的线性表!
插入元素
(1)考虑插入位置是否合法
(2)空间是否充足
(3)移动元素,腾出空位置
加入我们要将新的元素插入第i个位置,其下标为i-1,那从下标为i到length-1的元素都要往后移动一个位置
(4)放入要插入的元素
(5)线性表长度+1
int insertElem(sqList*L,int x, int i){ //需要传入写入的元素x和要插入第i个位置
//1.先判断位置是否合法
if(i < 0 || i > L->length + 1){ //假设有五个元素,最多可以插到第六个位置
return 0; // 失败
}
//2.检查空间够不够
if(L->length == L->maxSize){
L->data = (int*)realloc(L->data,(L->maxSize + 1) *sizeof(int));
//....省略空间失败容错检查
L->maxSize ++;
}
//3.移动元素
int j;
for(j = L->length - 1; j >= i-1; j--){
L->data[j+1] = L->data[j];
}
//4.放置要插入的元素
L->data[i-1] = x;
//5.表长增加
L->length ++;
return 1;
}
留个小问题,顺便复习一下上次总结过的内容,这个算法的时间复杂度为?
删除元素
要删除在线性表中第i个元素
(1)检查表是否为空
(2)检查i的位置是否合法
(3)移动元素位置 第i个元素后的每一个元素都要向前一个位置
(4)表长减一
int insertElem(sqList*L,int x, int i){ //需要传入写入的元素x和要插入第i个位置
//1.先判断位置是否合法
if(i < 0 || i > L->length + 1){ //假设有五个元素,最多可以插到第六个位置
return 0; // 失败
}
//2.检查空间够不够
if(L->length == L->maxSize){
L->data = (int*)realloc(L->data,(L->maxSize + 1) *sizeof(int));
//....省略空间失败容错检查
L->maxSize ++;
}
//3.移动元素
int j;
for(j = L->length - 1; j >= i-1; j--){
L->data[j+1] = L->data[j];
}
//4.放置要插入的元素
L->data[i-1] = x;
//5.表长增加
L->length ++;
return 1;
}
int deleteElem(sqList*L,int* x,int i){ //x用于获取被删的元素,要删第i个元素
//1.判断表是否为空
if(L->length == 0){
return 0;
}
//2.判断位置是否合法
if(i < 0 || i >= L->length-1){//假设有五个元素,那最多只能删到第五个元素
return 0;
//3.移动位置
*x = L->data[x-1]; //保存要被删的元素
int j;
for(j = i ; j <= L-length-1; j++){
L->data[j-1] = L->data[j];
}
//4.更新表长
L->length -- ;
return 1;
}
3.线性表的链式表示
即将线性表中的元素都存入链表中,特点是 逻辑上相邻,但物理实际上存储位置不相邻
需要通过指针把这些零零散散的地址连接起来
链表主要分为三种 单链表 双链表 和 单循环链表 和 双循环链表
(1)单链表
定义结点
首先我们需要先定义结点
一个结点里面需要包括这个元素的值以及下一个元素的地址
typedef struct node {
int data;
struct node *next;
} slink;
初始化单链表
在创建单链表时,与顺序表示不同之处在于,单链表不需要一个额外的结构体去储存链表的全局信息(包括首元素地址,表长,容量),因为通过指针我们就已经可以实现对链表的大多数操作。
可以想一下,如果用顺序表示,我们需要知道表内现在有多少元素来添加元素,判断插入删除的位置是否合法,需要知道最大容量判断能不能加入新元素等等,但是链表不需要
所以在初始化的时候,我们只需要一个空的头结点和一个头指针。
//不要写成这样
void initList(slink*L){
L = (slink*)malloc(sizeof(slink));
//容错判断 如果申请空间失败
if(!L){
printf("Overflow!");
exit(1);
}
L->next = NULL;
}
但是最好不要写成上面这样~
//最好写成这样
slink* initList(){
slink *L = (slink*)malloc(sizeof(slink));
//容错判断 如果申请空间失败
if(!L){
printf("Overflow!");
exit(1);
}
L->next = NULL;
return L;
}
为什么写成下面那种更好? (错过了才会知道的泪)
在上面那个版本中,L是函数的参数,传入的L为实际L的一个副本。
- 在 C 语言中,指针作为参数是按值传递的。也就是说,当你在
initList
中出初始化L
时,你修改的只是L
的局部副本,而不是调用方的实际指针。这导致在initList
函数内部分配的内存不会影响外部的L
,也就是说,实际的L依旧没有被初始化
注意L为头指针,指向的节点为头结点 需要注意 一般头结点不存放数据 头结点之后才会连接有效节点
创建单链表
将元素一个一个加入链表中主要有两种方法: 头插法 和 尾插法
尾插法
顾名思义 要把元素插到尾巴上去 ,需要一个尾结点的辅助
一共三个步骤:
(1)申请新空间存放数据
(2)连接新结点
(3)移动尾结点
slink* createFromTail(){
slink* L;
//将L初始化为空表
L = initList();
slink* r; // 尾指针指向表的尾节点
r = L; //最开始头节点也是尾结点
int x; //x用于存放数据
slink* s; //s用于指向新的数据存放的结点
scanf("%d",&x);
while(x!= -999){
//1.申请新空间储存数据
s = (slink*)malloc(sizeof(slink)); //这里容易错写成sizeof(int)
s->next = NULL;
s->data = x;
//2.连接新节点
r->next = s;
//3.移动尾指针
r = s;
scanf("%d",&x);
}
return L;
}
头插法
我学的时候感觉头插法更难理解一点的说(可能是我笨不嘻嘻)
总共分为三个步骤:
(1)为数据申请新的结点
(2)将新结点连到链表上
(3)让头结点的next指向新结点
代码部分前面都和尾插法完全一致,只有循环部分稍微有点不同
while(x!= -999){
//1.申请新空间储存数据
s = (slink*)malloc(sizeof(slink));
s->next = NULL;
s->data = x;
//2.连接新结点
s->next = L->next;
//3.改变头结点的next指向新结点
L->next = s;
scanf("%d",&x);
}
获取元素
在链表中,无法随机访问元素,所以只能通过遍历的方式获取
(1)检查参数是否合法
(2)遍历线性表
int getElem(slink*L,int i,int*x){
if(i < 1) return 0;
slink* p ; //p指针用于遍历线性表
p = L->next; //从有效结点开始遍历
int j = 1;
while(!p && j < i){
p = p->next;
j++;
}
if(!p) return 0; //i的值超过了链表的长度
*x = p->data;
return 1;
}
返回元素的位置
根据元素返回元素在线性表中的位置,依旧是通过遍历
slink* locateElem(slink*L,int x){
slink* p = L->next; // p指针用于遍历线性表
while(p && p->data != x){
p = p->next;
}
return p;
}
插入元素
假设要插入到第i个结点,则需要一个辅助指针遍历到第i-1个节点处
(1)判断要插入的位置i是否合法;
(2)将辅助指针指向第i-1个结点;
(3)为插入的元素申请新的结点;
(4)将新结点的next指向辅助结点的后一个结点,再把辅助指针的next指向新结点
int insertElem(slink* L,int i,int x){
slink *s,*r ; //s用于指向新结点,r用于遍历线性表指向第i-1个结点
r = L->next;
int j = 1; //记录当前r指针在第几个结点
//1.判断i是否合法
if(i < 1) return 0;
//2.将r指针指向第i-1个结点
while(r && j < i-1){
r = r->next;
j++;
}
if(!r) return 0 ; //r为空,i超过了表长
//3.为新元素申请新的结点
s = (slink*)malloc(sizeof(slink));
//..省略空间申请失败的容错
s->data = x;
//4.修改指针next指向
s->next = r->next;
r->next = s;
return 1;
}
删除元素
这里需要用到两个辅助指针
假设要删除第i个位置上的元素
(1)判断删除位置是否合法
(2)将r指针移到待删结点的前一个结点处;
(3)令s指针直接指向待删结点;
(4)r->next = s->next
(5)释放待删除结点;
int deleteElem(slink*L,int i,int *x){
slink *s,*r;
r = L->next;
int j = 1; //记录r移到了第几个结点
//1.判断i的位置合不合法
if(i < 1) return 0;
//2.将r指针移到第i-1个位置
while(r && j < i - 1){
r = r->next;
j++;
}
if(!r) return 0; //i超过了表长
//3.将s指向待删结点
s = r->next;
//4.删除结点并将删除的值带回
r->next = s->next;
*x = s->data;
//5.释放内存
free(s);
return 1;
}
(2)双链表
单链表只能从前往后遍历,但是双链表可以双向遍历;其实就是每个结点多了一个指向前一个元素的指针
定义结点
typedef struct node{
int data;
struct node *next;
struct node *prior;
}dlink; //doubleLinkedList
初始化双链表
dlink* initList(){
dlink*L;
L = (dlink*)malloc(sizeof(dlink));
L->next = L->prior = NULL;
return L;
}
头结点的next和prior都为空
创建双链表
尾插法
和单链表几乎是完全一样的
(1)为新元素创建新的结点
(2)连接新结点 会稍微有点不同 s->prior = r; r->next = s;
(3)移动尾指针
void createFromTail(dlink* L){
dlink *s,*r;
r = L;
int x;
scanf("%d",&x);
while(x!=-999){
//1.为新元素申请结点
s = (dlink*)malloc(sizeof(dlink));
//...省略处理申请空间失败的情况
//2.连接新结点
s->data = x;
s->next = NULL;
s->prior = r;
r->next = s;
//3.移动尾结点
r = s;
scanf("%d",&x);
}
}
头插法
核心步骤:
s->next = L->next;
L->next->prior = s;
L->next = s;
s->prior = L;
唯一要注意的地方就是当链表为空的时候,不需要L->next ->prior = s这一步
void createFromHead(dlink* L){
dlink *s;
int x;
scanf("%d",&x);
while(x!=-999){
//1.为新元素申请结点
s = (dlink*)malloc(sizeof(dlink));
s->data = x;
//...省略处理申请空间失败的情况
//2.连接新结点
s->next = L->next;
// 如果链表当前不为空(L->next不为NULL),需要更新原第一个结点的prior指针
if (L->next != NULL) {
L->next->prior = s;
}
L->next = s;
s->prior = L;
scanf("%d",&x);
}
}
删除元素
假设我们要删除第i个结点
一个指针就能完事
(1)判断删除的位置是否合法以及链表不为空(我经常会忘记要判断为不为空)
(2)让p指向待删的结点
(3)删除结点
p->prior->next = p->next;
p->next->prior = p->prior;(注意如果删的是最后一个结点,就不需要这个操作了!)
(4)释放内存 free(p)
int deleteElem(dlink*L,int i , int*x){
//1.判断位置是否合法以及链表不为空
if(i < 1 || L->next == NULL) return 0;
dlink*p = L->next;
int j = 1;
//2.使p指针移动到要删除的结点
while(p && j < i){
p = p->next;
j ++;
}
if(!p) return 0; //i大于表长
//带回要删除的值
*x = p->data;
//3.删除结点
p->prior->next = p->next;
if(p->next)//如果p指向的是尾结点,则不需要下面这个操作
p->next->prior = p->prior;
//4.释放内存
free(p);
return 1;
}
插入元素
假设要插入到第i个位置
把辅助指针指到待删结点的前一个位置或者后一个位置都行
其实从这里我们就能很明显的发现 双循环链表的一个特点了
从表中任意一个元素出现都能访问到其他位置的元素
只要通过不断的->prior 和 ->next就行
这里的代码我们以写辅助指针移动到待插入位置的前一个结点为例
int insertElem(dlink *L,int i,int x){
//1.判断插入位置是否合法
if(i < 1) return 0;
//2.为新元素申请结点
dlink*s = (dlink*)malloc(sizeof(dlink));
if(!s){
printf("Overflow!");
exit(1);
}
s->data = x;
//3.移动辅助指针
dlink*r = L->next;
int j = 1;
while(r && j < i-1){
r = r->next;
j++;
}
if(!r) return 0; //i大于表长
s->next = r->next;
if(r->next)
r->next->prior = s;//如果插入的位置恰好是最后一个结点,就不需要做这个操作
r->next = s;
s->prior = r;
return 1;
}
(3)单循环链表
特点:尾结点的next又指向头结点,从表中任意结点出发可以找到表中其他结点
与单链表的操作基本是相同的,区别就是在于循环条件
单链表: !p 或者是 p->next = NULL;
单向循环链表 p != L 或者是 p->next != L
(4)双循环链表
头结点的前驱指向尾结点,尾结点的后驱指向头结点;
特点:从表中任一结点出发均可找到表中其他结点
方便找到尾结点;
操作与双向链表基本一致;
区别仅在循环条件
双链表: !p 或者 p->next != NULL;
双循环链表 p != L 或者 p->next !=L
4.总结
线性表根据存储结构可以分为 顺序存储 和 链式存储两大类,从而分别产生了 顺序表 和 链式表
而链式表又可以分为单链表和双链表 单循环链表和双循环链表
对应每一种表 我们都讲了一些基本操作的具体实现
对比两种表顺序表和链式表
空间 | 时间 | |
顺序表 | 一个结点就为数据本身 存储密度大,空间利用率高 | 可以实现随机访问,时间短; 插入删除元素平均要移动一半的结点,时间长; |
链式表 | 一个结点中除了数据本身还存有其他必要的数据(next,prior) 存储密度小,空间利用率低 | 访问元素需要遍历,时间长; 插入删除元素只需要修改指针,时间短 |
如果有任何问题错误 欢迎在评论区批评指正讨论!!!