【数据结构】二.线性表

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)

存储密度小,空间利用率低

访问元素需要遍历,时间长;

插入删除元素只需要修改指针,时间短

如果有任何问题错误 欢迎在评论区批评指正讨论!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值