当当!欢迎进入数据结构的学习!
谈到数据结构,相信大家应该已经早就听过这个名词,但是什么是数据结构,大家应该还比较陌生
数据结构是由“数据”和“结构”两词组合⽽来。
什么是数据?常⻅的数值1、2、3、4…、学生系统⾥保存的⽤⼾信息(姓名、性别、年龄、学历等等)、⽹⻚⾥⾁眼可以看到的信息(⽂字、图⽚、视频等等),这些都是数据
那什么是结构呢?
当我们想要使⽤⼤量使⽤同⼀类型的数据时,通过⼿动定义⼤量的独⽴的变量对于程序来说,可读性⾮常差,我们可以借助数组这样的数据结构将⼤量的数据组织在⼀起,结构也可以理解为组织数据的⽅式。
举个例子,当我们想要在学生系统中找到你自己的名字,那实在很难,因为学生的人数太多了,你一个一个向下翻,可能要找很久;但如果你直接搜索你的学号,那一下子便会找到
总结一下:数据结构是计算机存储、组织数据的⽅式。数据结构是指相互之间存在⼀种或多种特定关系的数据元素的集合。数据结构反映数据的内部构成,即数据由那部分构成,以什么⽅式构成,以及数据元素之间呈现的结构。
通过数据结构,能够有效将数据组织和管理在⼀起。按照我们的⽅式任意对数据进⾏增删改查等操作。
最基础的数据结构——数组
数组我们应该都很熟悉,在C语言的学习当中,我们经常使用数组来存储同类型的数据,比如我们想存储1~100的数据,就会创建一个可以容纳100个int类型大小的一维数组。
乍一看很好用,其实在很多地方都受到了限制。比如数组无法自由改变空间大小,一旦创建就无法更改,而实际的应用中,数据的大小都是在实时发生变化的;再者,数组只可以存储一组同类型的数据,而现实生活中,一个人的信息往往要分成很多类别。而这些就导致了数组提供的接口不足以支撑复杂场景的数据处理,只会在简单的一些程序中使用到。
顺序表
- 顺序表是线性表的一种,而线性表是具有相同特性的一类数据结构的统称
- 线性表在逻辑结构上一定是线性的,但是在物理结构上(内存存储)不一定线性
- 而线性又是什么呢:
线性是指事物或对象按照一条直线或线段的方式排列或展开。在数学和计算机科学中,线性也可以指线性结构,即数据元素按照一对一的关系依次排列。线性结构中的每个元素都有一个前驱元素和一个后继元素,除了第一个元素没有前驱,最后一个元素没有后继。
我们之前使用过数组,知道数组的排列方式,举个例子:
int a[3]={1,2,3};
//数组 a 是一个包含 3 个整型数据的整型数组。
//数组a的内存布局:a[0]-->1,a[1]-->2,a[2]-->3
//每个整型数据通常占用 4 个字节的空间,因此每个数组元素占用 4 个字节的空间。
//相邻的数组元素在内存中是连续存储的,也就是说,它们的存储空间是相连的。
经过分析,我们可以发现,数组不仅仅在逻辑上是线性的,而且在物理结构上也是线性的,而顺序表的底层结构就是数组,也就是说,顺序表也拥有数组的这一特性。
顺序表又分为两种:静态顺序表和动态顺序表
typedef int SLDataType;
//静态顺序表
typedef struct SeqList
{
SLDataType a[10];//定长数组
int size;//数据的有效个数
}SL;
//动态顺序表
typedef struct SeqList
{
SLDataType* a;
int size;//数据有效个数
int capacity;//空间容量
}SL;
讲过数组,大家应该知道动态顺序表更好用,因为静态顺序表跟数组拥有一样的缺点,那就是空间给多了用不着,而给少了又不够用
而上面我们定义了一个变量叫SLDataType,大家应该能够发现它的用处,就是在使用顺序表时,我们可能会使用多种类型的数据,而这样使用,只需要在使用某种类型的时候将int改成别的数据类型,而不需要在程序中一个一个修改,更加简洁方便
顺序表实操
首先,我们需要创建一个头文件和两个源文件,头文件相当于目录,写结构的实现和声明以及函数的声明,而一个源文件写函数的具体实现,另外一个则是用来测试
如图:
而接下来我们就可以进行尝试:
//SeqList.h
typedef int SLDataType;
typedef struct SeqList{
SLDataType* a;
int size;//数据有效个数
int capacity;//空间容量
}SL;
void SLInit(SL* s);//函数声明
//SeqList.c
#include<stdio.h>
#include"SeqList.h"
//初始化顺序表
void SLInit(SL s){
s.a = NULL;//置为空指针
s.size = s.capacity = 0;
}
//test.c
#include<stdio.h>
#include"SeqList.h"
void SLTest(){
SL sl;
SLInit(sl);
}
int main()
{
SLTest();
return 0;
}
我们发现,运行的时候会出错,而出错的原因就是使用了sl这个未初始化的变量。那我们就会很奇怪啊,我们明明初始化了呀,这就犯了一个经典错误:误把形参当实参,形参的改变量是无法作用到实参上的,所以要想真正达到初始化的效果,就必须传址调用而不是传值调用,接下来我们进行修改:
//SeqList.c
#include<stdio.h>
#include"SeqList.h"
//初始化顺序表
void SLInit(SL* s){
s->a = NULL;
s->size = s->capacity = 0;
}
//test.c
#include<stdio.h>
#include"SeqList.h"
void SLTest(){
SL sl;
SLInit(&sl);
}
int main()
{
SLTest();
return 0;
}
而此时,才是真正正确的运用
//销毁顺序表
void SLDestroy(SL* s) {
if (s->a != NULL)//s->a要确实有动态开辟内存时,我们才能free
free(s->a);
s->a = NULL;//free之后要置为空指针,不free也要
s->size = s->capacity = 0;//有效个数和空间容量都变为0
}
另外还有四个需要实现的功能:增加数据、删除数据、修改数据,查找数据
1. 增加数据
增加数据分为尾插和头插,画一个图便于理解:
我们先讲尾插
- 首先,不管头插尾插,我们都需要确定一个问题,那就是内存空间的大小允不允许我们插入一个新的数据;如果空间不足,那么我们又需要扩容多少大小的空间来容纳新的数据呢?一般来说,都是扩容原来空间大小的两倍或者1.5倍
- 有些人可能会说,为什么不能插入一个扩容一块空间?首先,如果我们要插入50个数据,那么就需要扩容50次,频繁扩容会降低程序的性能,并且如果本来扩容的那一块内存不够,又会找到另一块空间进行扩容,拷贝原来的空间到这块略大一点的空间并删除原来空间储存的内容,虽然不会导致野指针的问题,但是程序运行的时候会较为麻烦,效率低下
而讲到扩容,我们就会想到动态内存管理使用的另一个函数:realloc,它可以自由改变指针所指向的那片空间的大小。于是,我们就可以根据顺序写出头插与尾插的函数了
//判断空间是否足够,不够就扩
void SLCheckCapacity(SL* s){
//空间不足以容纳其他数据,扩容
if (s->size == s->capacity)
{
int newCapacity = s->capacity == 0 ? 4 : 2 * s->capacity;//防止初始化空间为0的情况,如果是0就给4个空间,如果不是就原来空间×2赋给新值
//这个问题也可以在初始化的时候解决
SLDataType* tmp = (SLDataType*)realloc(s->a, newCapacity * sizeof(SLDataType));
//防止扩容失败将原来的指针指向的内容清空,用tmp先来
if (tmp == NULL)
{
perror("realloc");
return 1;
}
s->a = tmp;//扩容好再把指针指向tmp
s->capacity = newCapacity;//没问题就把原空间乘以2的值赋给capacity
}
}
//尾插
void SLPushBack(SL* s, SLDataType x) {
//如果一开始s指针就为空的话
if (s == NULL)
return;
SLCheckCapacity(s);
s->a[s->size++] = x;//s—>size的值就是指针指向的后一个位置,也就是扩容内存的第一个位置,++是因为有效数据+1
}
//头插
void SLPushFront(SL* s, SLDataType x) {
assert(s);
//扩容
SLCheckCapacity(s);
//所有原有内容后移一位
for (size_t i = s->size; i > 0; i--)
{
s->a[i] = s->a[i - 1];
}
s->a[0] = x;
s->size++;
}
可以看到,扩容的方法十分巧妙,不仅需要防止一开始初始化数据空间为0的情况,也需要防备realloc扩容失败而导致的后果,而并不是简单的让原空间*2就可以了
而头插和尾插,也因为插入的方式不同,一个是在原本a[s->size]的位置处插入,而另一个则是所有原有内容后移之后,直接空出第一个位置给插入。不过都不能忘记的地方就是size要++,毕竟有效数据+1了
2.减少数据
减少数据也分为尾删和头删,同样画个图给大家便于理解
删除元素比起添加元素少了一个问题,那就是空间大小是否足够,但另一个问题就是需要注意空间里是否还有足够的元素删除,这是删除元素的问题
尾删很简单,只需要让有效数据的个数-1,就可以达到删除数据的效果,而头删则需要移动数据的位置达到效果
bool SLIsEmpty(SL* ps)//判断是否还有有效数据
{
assert(ps);
return ps->size == 0;
}
void SLPopBack(SL* ps)
{
assert(ps);
assert(!SLIsEmpty(ps)); // 断言检查是否为空
ps->size--; // 减少 size,相当于删除最后一个元素
}
void SLPopFront(SL* ps)
{
assert(ps);
assert(!SLIsEmpty(ps)); // 断言检查是否为空
// 将后面的元素向前移动一个位置
for (size_t i = 0; i < ps->size-1; i++)
{
ps->a[i] = ps->a[i + 1];
}
ps->size--; // 减少 size,相当于删除第一个元素
}
3.指定位置之前插入/删除数据
刚才我们写的都是头插尾插,头删尾删,位置上是无法自己选择的,但在一些数据结构的使用中,我们需要在指定位置插入或删除数据,这时候之前我们使用的函数效率就比较低下了,这时候,我们就需要研究如何在指定位置之前插入/删除数据
void SLInsert(SL* ps, int pos, SLDataType x)//指定位置之前插入
{
assert(ps);
assert(pos >= 0 && pos <= ps->size);//0和ps->size就是头插和尾插
SLCheckCapacity(ps);//扩容
for (size_t i = ps->size-1; i > pos-1; i--)
{
ps->a[i + 1] = ps->a[i];//从后往前,逐个后移
}
ps->a[pos] = x;//赋值
ps->size++;//有效数据+1
}
void SLErase(SL* ps, int pos)//指定位置之前删除
{
assert(ps); // 断言检查是否为NULL
assert(!SLIsEmpty(ps)); // 断言检查是否为空
assert(pos >= 0 && pos < ps->size);
for (size_t i = pos; i < ps->size - 1; i++)
{
ps->a[i] = ps->a[i + 1]; // 将后面的元素向前移动一个位置
}
ps->size--; // 减少 size,相当于删除指定位置的元素
}
4.查找指定元素
最后一个函数就是用来在顺序表中查找指定元素,并返回一个布尔值表示是否找到该元素
bool SLFind(SL* ps, SLDataType x)
{
assert(ps);
for (size_t i = 0; i < ps->size; i++)
{
if (ps->a[i] == x)//找到了
return true;
}
return false;//没找到
}
以上就是顺序表的一个简单介绍,并且在集齐这些函数之后,顺序表的功能也已经基本完备,可以正常使用,而之后的文章中,我将会给大家展示顺序表的一些简单运用,希望大家在看完文章之后也能够理解!