数据结构学习分享之顺序表详解


1. 前言

在前一个章节中我们介绍到, 数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。 那么具体有哪些结构是我们常常用来存储数据的呢?今天就给大家讲解其中的一个结构:顺序表, 本篇文章将收录于数据结构学习分享专栏,有兴趣阅读更多关于数据结构知识的可以点点订阅,持续更新中ing~~.

想看顺序表所有代码的同学,我的码云放在了最后


2. 线性表

在我们认识顺序表之前,我们先来了解一些线性表的概念:

  • . 线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串…
  • 线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,线性表在物
    理上存储时,通常以数组和链式结构的形式存储。

我们要讲的顺序表本质上就是一个数组,通过数组形式来存储数据的结构,但是在数组的基础上,它要求数据从头开始存,并且是连续存储,不能跳跃间隔



3. 顺序表

3.1 概念以及结构

顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况采用数组存储。在数组上完成数据的增删查改。 我们说其实所有的数据结构都离不开增删查改,因为它的作用是用来存储数据,就得满足这几个需求

我们通常还讲顺序表分为两种:

  • 静态顺序表

  • 动态顺序表

3.11 静态顺序表

静态顺序表就是使用定长数组来存储数据,我们通常在写静态顺序表的时候将数组大小用define定义宏的形式定义在开
但是静态顺序表有很大的缺陷,

  • 那就是我不知道我要存储的数据到底有多大,假如我的静态空间为1000,但是我只存储了100个数据,这样的空间浪费是很可怕的
  • . 又比如我们想存500个数据,但是我们的静态空间只有400个,这也很苦恼.所以我们这一节只给大家实现动态顺序表,使用动态顺序表来弥补这种缺陷

3.12 动态顺序表

顾名思义就是通过动态开辟空间来存储数据的顺序表,这里我们可以使用malloc,realloc等函数,实现它数据多了我们就扩容这种功能


4. 顺序表的实现

4.1 顺序表内容的命名

我们之前说我们存储数据的结构要实现增删查改这些功能,这里我们给出了很几个函数来实现这种功能,分别是: 尾插(在末尾插入数据),尾删(删除末尾的数据),头插(从开头插入数据),头删(删除开头的数据),还有一个销毁顺序表的函数和初始化函数 ,我们将这些名字统一命名为:

  • SeqListPushBack(尾插)

  • SeqListPopBack(尾删)

  • SeqListPushFront(头插)

  • SeqListPopFront(头删)

  • SeqListDestory(销毁顺序表)

  • SeqListInit(初始化链表)

这里的前缀Seqlist就是顺序表的意思,Push就是插入,Pop就是删除.我们这样命名这些函数的意义有两个,一是别人可以一眼看出我们写的函数是为了实现什么功能,增强了代码的可读性,二是这些函数名的出处来自于C++的STL库,在我们之后的C++学习中会遇见这些名字,所以我们跟着这个STL库来取名字是没有问题的
_
如果你想取别的名字,比如a,b,c啥的也是没问题的,你自己能看懂就行,但是我不推荐这种做法,我们的代码风格也是很重要的.


4.2 初始化结构

像这种比较正式的代码我们都不会在一个.c文件中完成.在我们编写代码之前,我们需要创建三个文件,分别是:

  • test.c文件—用来测试你写的顺序表能不能用

  • SeqList.c文件—函数的代码实现

  • SeqList.h文件—函数的声明和库函数的包含


我们先来定义一个结构体:

struct SeqList
{
    
    
	int* a;//创建的数组用来存储数据
	int size;//size代表数组中存储了多少个有效数据
	int capacity;//表示数组实际能存储的空间有多大
};

我们会发现,我们的结构体中的数组定义用的是int类型,那假如我们想存储char类型或者float类型的数据我们就要把我们所有代码中的int全都改了,所以我们这个地方借助typedef来重命名一下我们的类型,并且我们还可以将我们的结构体一块重命名了,修改代码后:

#pragma once//在.h文件中操作
#include<stdio.h>//包含常见的头文件
#include<string.h>
#include<stdlib.h>
#include<assert.h>
#include<malloc.h>
typedef int SLDataType;//想存什么类型就把int改成什么
//
typedef struct SeqList
{
    
    
	SLDataType* a;
	int size;//size代表数组中存储了多少个有效数据
	int capacity;//表示数组实际能存储的空间有多大
}SL;//将struct SeqList重命名为SL,方便后面写代码


4.3 初始化函数

我们定义了结构体后没有将结构体初始化,所以我们写一个函数来将它初始化一下:(如果你不理解我们要传地址,别慌,后面会讲)

void SeqListInit(SL* ps)//初始化
{
    
    
	ps->a = NULL;//这里代表数组里面最开始什么都不放
	ps->size = ps->capacity = 0;//容量和空间都为0
}

写完这个函数后,我们就可以在test,c文件中先调用一下它,看看有没有问题

#include"SeqList.h"//包含头文件
void TestSeqList1()//这里我们在test.c文件中创建了一个测试函数,它的目的是为了测试我们写的SeqList.c文件中的代码是否正确
{
    
    
	SL s1;//定义一个结构体(SL为重命名前的struct SeqList)
	SeqListInit(&s1);
}
int main()
{
    
    
	TestSeqList1();
	//TestSeqList2();
	return 0;
}


4.4 扩容函数

我们在初始化结构体时,将数组的长度设置为空,代表它是没有空间的,所以我们在进行插入数据操作之前,应该先判断我们数组中有没有空间?空间够不够?所以我们就引出了扩容,我们把扩容这个功能单独写成一个函数,当然你也可以在尾插函数或者头插函数中判断是否需要扩容,并且将扩容函数一起写到尾插/头插中,我们先上代码再解释:
(如果你不理解为什么要传结构体变量的地址,别慌,后面会讲)

void SeqListCheckCapacity(SL* ps) //判断是否需要扩容
{
    
    
	if (ps->size == ps->capacity)//两种情况,一种为顺序表没有空间,一种为空间不够(capacity已经满了)
	{
    
    
		int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;//当空间为0时给四个空间,不为0时将原先空间乘2;
		SLDataType* tmp = (SLDataType*)realloc(ps->a, newcapacity * sizeof(SLDataType));//当我们的数组a的空间为0时,realloc的功能相当于malloc
		if (tmp == NULL)//判断动态开辟空间是否成功,如果不成功就退出程序
		{
    
    
			exit(-1);
		}
		ps->a = tmp;//将数组a指向我们开辟成功的这份空间
		ps->capacity = newcapacity;//将数组的容量赋值为新的容量
	}
}

当我们的size等于capacity时,也就是数组存储有效数据个数和空间容量大小相同时,有两种情况,一是size和capacity都为0,也就是没有空间,还有一种就是空间被存储满了. 所以这个地方我们用了三目运算符来判断是哪一种情况,如果是没有空间的情况,那我们先给它4个空间(当然如果你愿意你也可以给他8个,6个,看你的意愿),如果是空间已经满了的情况,那我们就将它的空间给扩容成原来的两倍(当然如果你愿意你也可以扩容为原来的三倍,也是看你自己的意愿).最后将我们的数组指针a指向我们刚刚开辟的这份空间.



4.5 尾插函数

在我们初始化之后,就可以开始我们的尾插了,先上代码再解释:
( (如果你不理解为什么要传结构体变量的地址,别慌,后面会讲) )

void SeqListPushBack(SL* ps, SLDataType x)//尾插
{
    
    
	assert(ps!=NULL);//传进来的ps结构体不能为空
	SeqListCheckCapacity(ps);//这个地方我们只需要一级指针,传ps而不是&ps,&ps是二级指针了
	ps->a[ps->size] = x;//size位置刚好是数组最后一个元素的后一个位置
	ps->size++;//尾插后讲有效数据加1
}

在我们尾插之前,我们一定要判断我们的数组存储的数据是不是已经满了(甚至是不是没有空间)
我们现在可以在test.c中测试一下我们写的尾插函数有没有问题:

#include"SeqList.h"
void TestSeqList1()
{
    
    
	SL s1;//定义一个结构体
	SeqListInit(&s1);
	SeqListPushBack(&s1,1);//尾插1 2 3 4
	SeqListPushBack(&s1,2);
	SeqListPushBack(&s1,3);
	SeqListPushBack(&s1,4);
}
int main()
{
    
    
	TestSeqList1();
    return 0;
}

写完后如果你想看你是否插入成功了,我们可以取调试窗口查看,但是我觉得这样每次都去看调试很麻烦,所以我写一个打印函数,可以讲数组的内容打印出来



4.6 打印函数

void SeqListPrint(SL* ps)//打印
{
    
    
	for (int i = 0; i < ps->size; i++)
	{
    
    
		printf("%d ", ps->a[i]);
	}
	printf("\n");
}

写完打印函数后我们再去验证一下上面写的尾插:

#include"SeqList.h"
void TestSeqList1()
{
    
    
	SL s1;//定义一个结构体
	SeqListInit(&s1);
	SeqListPushBack(&s1,1);//尾插1 2 3 4
	SeqListPushBack(&s1,2);
	SeqListPushBack(&s1,3);
	SeqListPushBack(&s1,4);
	SeqListPrint(&s1);
}
int main()
{
    
    
	TestSeqList1();
    return 0;
}

我们的1 2 3 4 就被打印出来了:

在这里插入图片描述



4.7 尾删函数

先上代码再解释:

void SeqListPopBack(SL* ps)//尾删
{
    
    
	assert(ps);//ps不能为null
	assert(ps->size > 0);//size必须大于0,不然是没有数据可以删除的,并且size--后,
	                     //size会等于负数,下次再使用ps->a[size]时会报错
	ps->size--;//直接将size减1,我们的数组就访问不到最后一个数据了
}

这里大家可以自己去测试一下我们的尾删代码:
在这里插入图片描述



4.8 头插函数

void SeqListPushFront(SL* ps, SLDataType x)//头插
{
    
    
	assert(ps);
	SeqListCheckCapacity(ps);//判断是否需要扩容
	int end = ps->size-1;//将end指向数组的最后一个元素
	while (end >= 0)
	{
    
    
		ps->a[end + 1] = ps->a[end];//将前面的数据往后挪动,而且必须是从最后一个数据开始挪
		end--;
	}
	ps->a[0] = x;//将插入的数据放在第一个位置
	ps->size++;//将有效数据加1
}

这里我还是说明一下为什么我们挪动数据的时候要从后面开始挪动:

在这里插入图片描述
这里大家可以用test.c文件去测试一下:



4.9 头删函数

和尾删一样,头删之前也要判断我们的顺序表是否为空.

void SeqListPopFront(SL* ps)//头删
{
    
    
	assert(ps);
	assert(ps->size > 0);
	for (int i = 1; i < ps->size; i++)
	{
    
    
		ps->a[i - 1] = ps->a[i];//将后面的数据往前挪动,并且要从最开头开始挪动,原因和我们之前的头插是一样的
	}
	ps->size--;//挪动完后将size减1
}


4.10 销毁顺序表

当我们使用完顺序表表后,需要将它销毁掉,并且下次使用时再重新初始化:

void SeqListDestory(SL* ps)//销毁顺序表
{
    
    
	free(ps->a);//把a指向的空间释放掉
	ps->a = NULL;//将指针a置空
	ps->size = 0;//将size和capacity都置空
	ps->capacity = 0;
}


5. 写代码时应该注意的点

  • . 我们在写尾插,尾删,头插,头删…时,我们将结构体的地址传过去,并且用指向接受,这时因为我们需要改变结构体里面的内容,和我们之前讲过的传值调用和传址调用一样,但是我们的打印函数其实是可以不传地址过去的,因为它不改变原来的内容只是实现一个打印功能,但是为了这个地方函数声明的统一美观,所以我这里也传了地址过去,当然你们也可以不传地址

  • 我们在实现头插和尾插的时候,首先要判断顺序表的空间足不足够,或者有没有空间,而在我们实现头删和尾删的时候,首先要判断顺序表中还有没有内容给我们删除,如果你不做判断,多删了内容,可能这时是不会报错的,但是你在销毁顺序表时会遇见错误

  • 写代码时应该注意我们的命名风格,因为我们的代码不仅仅是给自己看的,更重要的是别人能够读懂!如果命名风格不好,甚至你自己过了段时间你都不知道自己写的是什么意思.


6. 顺序表问题思考以及题目推荐

问题:
1. 顺序表的中间/头部的插入删除,时间复杂度为O(N)
2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们
再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。

思考:如何解决以上问题呢? 我们下一章将会为大家揭晓!


相关题目:

  • 原地移除数组中所有的元素val,要求时间复杂度为O(N),空间复杂度为O(1)。OJ题链接
  • 删除排序数组中的重复项。OJ题链接
  • 合并两个有序数组。OJ题链接


7. 总结

本章给大家讲解完了数据结构中最简单的结构—顺序表的增删查改,其实我们的顺序表还可以实现查找特定位置的数据,
删除特定位置的数据的功能,这里我只给出了我们最简单的功能的实现,有兴趣的同学可以去我的码云看看所有的代码.

下章预告:单链表

我的码云:gitee:杭电码农

猜你喜欢

转载自blog.csdn.net/m0_61982936/article/details/130435228