1、数据结构
1.什么是数据结构
数据结构是由”数据“和“结构”两词组合而来。
数据结构中的“数据”是什么呢?
可以是常见的数字1、2、3、4……、可以是某个app后台的用户信息(姓名、年龄等等)、也可以是我们上网冲浪看到的信息(短视频、图片等等)。
数据结构中的“结构”是什么呢?
简单来说就是把大量数据组织在一起的方法,分为逻辑结构和物理结构(存储结构)。
2、顺序表
1.什么是顺序表
顺序表是线性表的一种,它用一组地址连续的存储单位依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻。顺序表具有动态分配空间、支持随机访问和顺序访问的特点,逻辑顺序与物理顺序一致。每个元素都可以有唯一的位置,可以通过索引直接访问元素。元素可以是任意类型,包括基本数据类型、结构体等,而且顺序表的底层结构是数组,对数组的封装,实现了常⽤的增删改查等接⼝。
顺序表分为静态顺序表和动态顺序表
-
静态顺序表
-
使用定长的数组来储存元素,他的缺陷也很明显就是,空间的分配把握不好,空间给少了不够用,但是给多了还会造成空间的浪费。
动态顺序表 -
它与静态顺序表的区别就是它把定长的数组改变成了一个指针,而指针所指向的空间的大小是可以调整的,这样就可以灵活的改变顺序表的大小。
2.如何实现顺序表
因为静态的顺序表就是对数组的操作是很基础的,所以我们在这里要实现的是动态的顺序表,分为一下几步。
第一步:动态顺序表的构建
#define INIT_CAPACITY 4 //这个后面会提
typedef int SLDataType; //动态顺序表可以存储的不只是整型(int)
typedef struct SeqList //为了后面方便定义顺序表为SL
{
SLDataType* a; // 动态顺序表存储部分--按需申请
int size; // 有效数据个数
int capacity; // 空间容量
}SL;
第二步:动态顺序表的初始化
//初始化动态顺序表
void SLInit(SL* ps);
我们看一下SLInit函数的参数,是一个动态顺序表(SL)指针,也就是我们创建一个SL a变量然后把&a传入函数种,然后初始化它。现在我们实现一下它。
void SLInit(SL* ps)
{
assert(ps);//断言,ps不能为空指针(NULL)
//使动态顺序表的空间先为空,代表动态顺序表中没有数据
ps->a = NULL;
ps->size = 0; //顺序表的有效数据个数为0
ps->capacity = 0; //顺序表的容量为0
}
Q:为什么要对ps断言呢?
因为如果ps为空指针(NULL)的话我们无法对空指针(NULL)进行解引用,会出现错误。
第三步:动态顺序表的扩容
我们要想让顺序表变得动态就,扩容操作是必不可少的。
void SLCheckCapacity(SL* ps);
我们来看一下SLCheckCapacity函数的参数,也是只有一个动态顺序表的指针,现在我们来实现它
void SLCheckCapacity(SL* ps)
{
//断言,ps不能为空指针(NULL)
assert(ps);
//定义一个新容量(NEWcapacity),并给它赋值
int NEWcapacity = ps->capacity == 0 ? INIT_CAPACITY : 2*ps->size;
//创建一个类型为动态顺序表(SL)中存储的数据类型(SLDataType)的临时指针变量
//并给它申请空间
SLDataType* tmp = (SLDataType*)realloc(ps->a, NEWcapcacity * sizeof(SLDataType));
//如果申请空间失败就退出程序
if(tmp == NULL)
{
perror("SLCheckCapacity error!");
exit(1);
}
//将申请到的空间赋给动态顺序表(SL)中存储数据的部分
ps->a = tmp;
//更新新容量
ps->capacity = NEWcapacity;
}
-
说明
- 1. 断言的原因仍是因为空指针(NULL)不可以解引用
- 2. 定义新容量时我们用到了一个三目操作符“ ? :”,并且回收了前面的伏笔我们定义的INIT_CAPACITY这两的结合的作用主要是当这个动态顺序表的容量为0时我们给它最基础的一个值就是我们自己定义的INIT_CAPACITY,而当动态顺序表(SL)的容量不为0但是还需要扩容时我们就会给它扩容到当前有效数据两倍的容量,那为什么时两倍呢,我们下面来说明
- 3. 我们的计算机系统是基于二进制的系统,在二进制数据中,一个位可以表示0或1,而每8个位组成一个字节。因此,为了确保内存的分配和管理效率,我们的内存设计成2n,这样能够确保内存的地址空间更加均匀和高效地分配。
- 4. 这里我们使用了reallco是因为我们要进行在原来空间的基础上的扩容,扩容的内存地址是动态顺序表(SL)的存储部分,扩容为一个动态顺序表存储类型(SLDataType)的新容量(NEWcapacity)倍。
第四步:动态顺序表的简单插入
动态循序表的简单数据插入分为头部插入和尾部插入。
//动态顺序表的头部插入
void SLPushFront(SL* ps, SLDataType x);
//动态顺序表的尾部插入
void SLPushBack(SL* ps, SLDataType x);
我们来看一下这两个函数的参数,这两个函数都有两个参数分别是动态顺序表(SL)的地址,和要插入的参数,现在我们来实现一下它。
//头部插入
void SLPushFront(SL* ps, SLDataType x)
{
//断言,ps不能为空地址(NULL),ps->a也不能为空指针(NULL)
assert(ps);
//判断是否要扩容
if(ps->size == ps->capacity)
SLCheakCapacity(ps);
//从后往前移动数据
for(int i = ps->size;i > 0;i--)
{
ps->a[i] = ps->a[i-1];
}
//插入数据
ps->a[0] = x;
//有效数据数增加
size++;
}
-
说明
-
1.断言的原因还是不能对空指针(NULL)进行解引用
-
2.如果有效数据(size)的个数和容量(capacity)相等我们就必须进行扩容才能添加新数据
-
3.因为我们要在动态顺序表的前面插入数据,所以我们要把已有的有效数据(size)后移
Q:那我们为什么要从后往前移动动态顺序表(SL)里的数据呢?
这是因为我想要把原有数据都向后移动一个单位内存并把它们全都保留的话,从前往后移动是不可以的会使数据丢失,就像图中在从前往后移动一次后数据“2”丢失了
但是如果我们使用从后往前移动数据就不会丢失数据,如图就是从后向前移动一次数据的情况,在移动完数据“4”后其它的数据都还在
现在我们来看一下尾部插入数据
//尾部插入
void SLPushBack(SL* ps, SLDataType x)
{
//断言,ps不能为空指针(NULL)
assert(ps);
//判断是否要扩容
if(ps->size == ps->capacity)
SLCheakCapacity(ps);
//插入数据,有效数据数增加
ps->a[size++] = x;
}
动态顺序表(SL)的尾部插入十分简单,因为只要知道有效数据的个数就可以
Q:最后一行的"ps[size++] = x"如何解读呢?
一、“size++”怎么解读:首先“++”是一个单目运算符,因为我们在这里用的是后置“++”所以size会先使用后进行“++”操作,也就是说会先执行“ps[size]”后执行“size++”,这种写法起到代码简洁干净的作用。
二、“ps[size]”怎么解读:因为动态顺序表(SL)的存储部分就相当是一个数组所以数据的下标是从0开始的那“ps[size]”就正好访问了我们有效数据(size)的后一个存储单元
第五步:动态顺序表的简单删除
动态顺序表的简单删除和简单插入一样分为头部删除和尾部删除
//动态顺序表的头部删除
void SLPopFront(SL* ps);
//动态顺序表的尾部删除
void SLPopBack(SL* ps);
现在我们来看一下,这两个函数的参数都是只有动态顺序表(SL)的指针,我们开始实现它们
void SLPopFront(SL* ps)
{
//断言,ps不能为空指针(NULL),ps->不能为空指针(NULL)
assert(ps);
assert(ps->a);
//把数据从前往后移动一个数据单位
for(int i = 1; i < ps->size; i++)
{
ps->a[i-1] = ps->a[i];
}
//有效数据个数减一
size--;
}
-
说明
- 1. ps->a不能为空指针(NULL)是因为如果ps->a是空指针(NULL)有效数据(size)就为0,就没有能删的数据
Q:这里为什么从前往后移动数据呢?
和上面的一样如果这里从后往前的移动数据就会数据丢失,就像图中在从后往前移动一次后数据“3”丢失了
而如果我们从前往后的移动数据,就像图中的一样我们想保留的数据都没有丢失
我们再来看一下尾部删除
void SLPopBack(SL* ps)
{
//断言ps不能为空指针(NULL),ps->a也不能为空指针(NULL)
assert(ps);
assert(ps->a);
ps->size--;
}
这就是我们代码的全部
Q:那为什么我们的用一句代码“ps->size--”就实现了我们的尾部删除呢?
因为只要我们的有效数据(size)减少1我们的有效的存储数据的最后一个也就是尾部就会减少一个数据
第六步:动态顺序表的复杂插入和删除
//在指定位置之前插入数据
void SLInsert(SL* ps, int pos, SLDataType x);
//在指定位置之前删除数据
void SLErase(SL* ps, int pos);
//查找数据位置
int SLFind(SL* ps, SLDataType x);
我们分析一下这些函数,SLInsert函数的参数多了一个我们指定的要插入的位置(pos),注意是位置而不是下标,SLErase函数也是一样,现在我们来实现它们
void SLnsert(SL* ps, int pos, SLDataType x)
{
//断言ps不能为空指针(NULL)
assert(ps);
//指定位置(pos)不能小于1大于有效数据加一(size+1),等于有效数据加一(size+1)就等于尾插
assert(pos >= 1 && pos <= ps->size+1)
//判断是否扩容
if(ps->size == ps->capacity)
SLCheakCapacity(ps);
//从后往前移动数据
for(int i = ps->size; i >= pos; i--)
{
ps->a[i] = ps->a[i-1];
}
//插入数据
ps->a[pos-1] = x;
//有效数据个数增加
ps->size++;
}
Q:那为什么我要用pos代表数学中的位置而不是数组中的下标
这个问题就是我自己的一点私心了,因为我觉得如果让一个外行人也能使用的话这种表达方式很好,但是在代码的表现上会有一点绕
我们再来看一下在指定位置之前删除
void SLErase(SL* ps, int pos)
{
//断言ps不能为空指针(NULL)
assert(ps);
//指定位置(pos)不能小于1大于有效数据加一(size+1),等于有效数据加一(size+1)就等于尾删
assert(pos >= 1 && pos <= ps->size+1)
//从前往后移动数据,会覆盖掉
for(int i = pos; i < ps->size ; i++)
{
ps->a[i-1] = ps->a[i];
}
//有效数据减少
ps->size--
}
最后我们来实现一下如何得到指定位置
int SLFind(SL* ps, SlDataType x)
{
//断言ps不能为空指针(NULL)
assert(ps);
//遍历数组
for(int i = 0; i < ps->size ; i++)
{
if(ps->a[i] == x)
{
//返回的是位置不是下标
return i+1;
}
}
//返回0,的意思是没有找到
return 0;
}
3.顺序表的缺点
1. 插入和删除操作复杂:由于顺序表是连续存储的,当需要插入或删除元素时,需要移动其他元素来腾出空间或填补空缺,因此时间复杂度为O(n),其中n是元素的个数。
2. 存储空间固定:顺序表在初始化时需要指定固定大小的存储空间,如果元素数量超过了预设的大小,需要进行扩容操作,这可能导致时间和空间的浪费。