文章目录
前言
大家好,这是我第三次写文章了,本篇文章将带着大家主要学习顺序表的数据结构知识。
本来想把链表也一块写了的,但顺序表内容的太多,所以就放到下次文章了。
一、线性表
线性表是n个具有相同特性的数据元素的有限序列。
你可以理解它为一种数据结构,在实际中被广泛使用,常见的线性表有:顺序表、链表、栈、队列、字符串……
没错就连字符串都可以看成一个线性表,"abcdef "它也符合我们对线性表所下的定义。
而线性表在逻辑上是线性结构,连续的一条直线。
但线性表在物理结构上却不一定是连续的,线性表在存储时,通常以数组和链式结构的形式存储。
二、顺序表
2.1概念及结构
顺序表是用一段物理地址连续的,存储单元依次存储数据元素的线性结构,和数组很相似。
简而言之,顺序表就是在地址连续的存储空间里存放数据的。
顺序表一般可以分为:
1. 静态顺序表:使用定长数组存储元素。
所谓静态顺序表,意味着它的空间是从一开始就固定的,无法后续在程序运行过程中再进行扩展。
定义代码如下:
#define N 7
typedef int SLDataType;
typedef struct SeqList
{
SLDataType a[N];//定长数组
size_t size;//有效数据的个数
}SL;
我们利用结构体定义了一个存放整型数据的一个顺序表。
其中我们将 int 重命名为 SLDataType ,这是为了后续我们能够直观的看出我们存放的是顺序表数据,增加我们代码的可读性。
(up猜的)在后续参加一些大型项目时,使用 int 用的地方很多,但如果所有结构都直接用 int ,那么我们在后续进行调试和改进可能会不方便识别处理。
size代表我们存放数据存放的个数,静态顺序表是无法在运行过程中扩容的,为了之后判断空间是否已经满了,所以这个变量还是很有必要的。
静态顺序表无法自己扩容,这也导致我们在存储数据时会很不方便,也就引出了后续我们要学习的第二种顺序表———动态顺序表。
2.动态顺序表:使用动态开辟的数组存储。
我们存储数据时,都希望存储数据就只是存储数据,不希望还有额外需要我们消耗精力去考虑其他事情。
就好比,在存储数据的时候,数据已经全部输入了,一上传给你个提示,“警告,空间不足,存入失败",我真的,心脏骤停。
所以动态顺序表很好的解决了这个问题,我们在输入数据后,如果空间不足,它会自动扩容,不需要我们再手动申请,也能够很好的提高我们的效率。
定义代码如下:
//顺序表的动态存储
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* a;//指向动态开辟的数组
size_t size;//有效数据的个数
size_t capicity;//容量空间的大小
}SL;
通过以上代码,我们就可以定义一个动态开辟的顺序表了。
其实,我们不难发现,原来静态顺序表使用的是定长数组,而现在的动态顺序表则是将定长数组的形式改成了一个顺序表类型(实质还是 int 类型)的指针(SLDataType *),在后续中,它负责指向我们存入数据的第一个地址,便于我们后续的对数据的遍历与增删查改。
对比静态顺序表和动态顺序表的定义代码,我们可以发现多了一个size_t类型的capicity,这个变量表示着动态顺序表的容纳空间大小,我们在动态开辟空间时,需要判断存入的数据数量是否达到了最大可容纳空间,然后再继续开辟新的空间。
以上便是我们定义顺序表时一些注意的点,重要是理解,我们为什么这样写定义顺序表的代码,理解这点后,我们的思路才会清晰,不会停在某步,不知道下一步该怎么写代码。
2.2动态顺序表的代码实现
接下来,我将实操一遍代码以便大家更好的了解动态顺序表的大致功能,其中主要功能就是增删查改,而在后续的链表中,这一部分内容也会在其中体现。
注意:这一部分的代码并不全面,只包含动态顺序表的大致功能,其中缺少的头文件等内容,请读者自行补充。
//动态顺序表的定义
typedef int SLDataType;
#define INT_CAPCITY 4 //定义申请空间的大小
typedef struct SeqList
{
SLDataType* a;//指向动态开辟的数组
size_t size;//有效数据的个数
size_t capicity;//容量空间的大小
}SL;
2.2.1 动态顺序表的初始化
- 首先便是初始化一个动态顺序表,让它先申请空间,以便我们后续在此基础上更好的增加内容。
void SLInit(SL *ps)
{
assert(ps);
ps->a =(SLDataType*)malloc(sizeof(SLDataType)*INIT_CAPACITY);
if (ps->a == NULL)
{
perror("malloc fail");
return;
}
ps ->size = 0;
ps -> capacity = INIT_CAPACITY;
}
由于形参的改变不会影响实参,所以我们将创造的顺序表的变量地址传给指针ps,为避免我们创造的顺序表地址为空,所以,我们先用断言assert判断ps是否为空,若不为空。继续执行下列代码。
首先,用malloc函数开辟一个大小为INIT_CAPACITY(4)的空间,但并不存入数据,开辟失败则使用perror,并中断程序。开辟成功,则将ps指向的size(有效数据的个数)和capicity(容量空间的大小)分别赋值0与4,以便后续进行判断。
2.2.2 检查是否需要扩容的函数
- 接下来,我们需要检查顺序表空间是否已满的函数,进行扩容,防止我们后续空间不足,引起一系列问题。
void SLCheckCapacity(SL* ps)
{
if (ps->size == ps->capacity)
{
SLDataType* tmp = (SLDataType*)realloc(ps->a, sizeof(SLDataType)*ps->capacity * 2);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
//原地扩容
ps->a = tmp;
ps->capacity *= 2;
}
}
当有效数据的个数和容量空间相同时,也就意味着容量空间已满,我们需要利用realloc函数将ps->a地址的空间大小调整到原来空间的两倍,并赋值给tmp这个暂时被创造的变量,如果tmp后续为空,说明realloc开辟空间失败,return会中断程序,而perror会醒目的告知我们“realloc fail”,后续需要我们自己进行调试,查找问题。
若开辟空间成功,将tmp赋值给ps->a,并更新此时空间容量的大小为原来的2倍。
不直接使用ps->a,而使用tmp这个暂时的变量,其实就是为了防止开辟空间失败后,可能会导致我们丢失数据,所以创建tmp这个变量是必要的。
2.2.3动态顺序表的销毁
- 我们接下来则需要一个专门销毁动态顺序表的函数,防止后续出现野指针或者内存泄漏的问题。
void SLDestory(SL* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->capacity = ps->size = 0;
}
销毁动态顺序表,将我们在测试时创建的SL型的变量的地址传给ps,运用断言assert防止ps为空的情况,之后将ps->a的空间释放(我们之前malloc,realloc申请的空间都是在ps->a的上面),然后将ps->a置空,并将(ps->size)有效数据的个数和(ps->capacity)容量空间的大小均更新为0,此时,我们便完成了一个动态顺序表的销毁。
2.2.4动态顺序表的打印
- 为了方便我们观察动态顺序表的存储过程,我们预先写一个动态顺序表的打印函数。
void SLPrintf(SL* ps)
{
assert(ps);
int i = 0;
for (i = 0; i < ps->size;i++)
{
printf("%d ",ps->a[i]);
}
printf("\n");
}
运用循环,来打印有效数据。
- 在我们完成这些准备工作后,我们便可以正式的进行增减数据了
- 首先是插入数据,尾插与头插,还有一种在指定位置进行插入,我们先学习前两种的思想,最后再学习第三种
2.2.5 尾插
- 尾插就是在顺序表的末端进行插入,就像排队一样,第一个人排完,第二个人排到第一个人后面
void SLpushBack(SL* ps, SLDataType x)
{
assert(ps);
//扩容
SLCheckCapacity(ps);
//ps->a[ps->size] = x;
//ps->size++;
ps->a[ps->size++] = x;
以上就是动态顺序表尾插的函数,还是用assert进行检查ps是否为空,之后我们引入检查扩容的函数,这样我们在插入数据的时候,就不用再担心空间的问题了。
我们一般写的时候写的是被注释掉的两条代码,相信大家应该记得,我们刚才写的初始化一个动态顺序表时,有效数据定义的是0个,ps->size是从0开始,随着我们一个一个插入数据时,一个一个增加的,对应的我们将ps->a[ps->size]的空间插入数据X,之后将有效数据个数size++,我们便完成了尾插的编写。
未被注释的代码其实是上述代码的结合版。
2.2.6 头插
- 其实就是在顺序表的首端进行插入,就像穿糖葫芦一样,竹签的尖端想象成首端,数据就像山楂,一直通过尖端插入,随着山楂的不断插入,最开始的第一块山楂被插入到最后末端,体会这个过程,与我们头插代码的编写有着异曲同工之妙
void SLpushFront(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps);
int end = ps->size - 1;
while (end >= 0)
{
ps->a[end + 1] = ps->a[end];
end--;
}
ps->a[0] = x;
ps->size++;
}
进行头插时,与尾插不同的是我们之前插入的数据的位置改变了,像上面说的串糖葫芦一样,使用头插的方法,原来第一个插入的山楂,在糖葫芦串好后,位置变成了最后一个,发现了么?头插每插入一个数据,需要我们将之前的数据全部向后移动,而尾插并不需要,这便是两者之间的差别。
明白以后,我们开始讲解代码,断言和检查扩容就不说了,在头插前,我们先将之前插入的数据整体后移,如果我们先头插再后移数据,会导致我们之前插入的一系列数据中,第一个数据被新头插的数据所覆盖,导致我们的数据丢失,所以必须是先移动数据再进行头插,这个顺序不可以改变。
首先我们定义一个end变量定为有效数据-1,为什么要这么定义呢?end的意思是结束,我们想的是用end来作为下标来查出对应的数据,类似数组,下标是从0开始的,这样一来,当end恰好为0时,我们也就刚好将原来排好的数据整体后移了一个单位,此时,第一个数据也就是a[0]的位置恰好空出来了,而我们刚好可以将新的数据头插到第一个位置,之后更新有效数据个数,这样以来,头插的工作便完成了。
可能大家看到这里很不理解,up,up,我尾插最多两行就搞定了,你这头插多麻烦,这么多行代码,而且还得把其他数据整体移一次,时间复杂度不更高了?
欸~,你说对了,我也是这么想的(俺也一样),但是,我们还是要学这个的,我们在刷题的时候,头插的方法往往在特殊情况有奇效,所以大家还是知道一下比较好。
2.2.6 在目标位置进行插入数据
- 第三种插入数据的方法便是我们指定某个位置进行插入数据,不过要注意一点就是在我们指定某个位置进行数据插入时,这个位置后的其他数据需要后移。
void SLInsert(SL* ps, int pos, SLDataType x)
{
assert(ps);
assert(pos >= 0 && pos <= ps->size);
SLCheckCapacity(ps);
int end = ps->size-1;
while (end >= pos)
{
ps->a[end + 1] = ps->a[end];
end--;
}
ps->a[pos] = x;
ps->size++;
}
因为是指定位置进行数据的插入,所以我们的函数需要多一个参数pos,来传递我们插入数据的位置。
而同时,pos的值不可以越界访问,为了防止这种情况的发生,我们使用assert对pos的值进行限制,使它在我们有效数据个数内。之后对ps进行检查是否需要扩容即可。
正如我们一开始说的,在一段数据中插入一个数据,这必定会导致后面数据向后移动一个单位,所以我们尝试从最后一个数据向前遍历。
定义最后一个数据的位置为end,如果end比pos的值要小,那么我们可以直接在pos位置进行插入。
反之,如果end比pos的值要大(说明我们需要在一段数据中进行数据的插入),那么我们则需要对顺序表进行遍历加移动。
从最后一个数据开始进行数据的遍历,直到end等于pos的值,在此之前,我们在从后向前遍历数据的同时,移动数据向后一个单位,这样一来,正好将pos位置之后的数据均向后移动一个单位,并将插入新数据X,更新有效数据的个数。
其实当我们写出来这个函数后,可以让它代替我们的头插与尾插,只需要在使用时,将pos的值改成0或者有效数据个数-1即可,在这里就不做演示了。
2.2.7 尾删
- 当我们介绍完插入数据的方法后,我们开始介绍数据的删除,同样是三种,尾删,头删,指定位置数据的删除,我们先介绍尾删。
void SLpopBack(SL* ps)
{
//暴力检查
assert(ps->size > 0);
//ps->a[ps->size - 1] = 0;
ps->size--;
}
尾删其实很简单,你只需要把顺序表中有效个数直接减1就行了,这样我们在使用顺序表时最后一个数据不再有效,实现其他操作时也不会再遍历到无效的数据,你可以抽象的理解为我们通过有效数据个数来辅助定位数据位置,而有效数据个数减1后,我们无法再定位到最后一个数据,也相当于删除了最后一个数据。
2.2.8 头删
- 头删就是把第一个数据删除,并将后面的数据进行前移,其实我们可以直接将第一个数据后的数据进行前移,这样就可以直接覆盖第一个数据,完成头删。
void SLpopFront(SL* ps)
{
assert(ps);
assert(ps->size > 0);
int begin = 1;
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
begin++;
}
ps->size--;
}
我们使用循环来完成这个操作,从第二个数据开始,将数据赋值给到前一个位置,直到所有数据赋值完成,最后更新有效数据个数即可。
2.2.9 删除pos位置的数据
- 接下来我们来讲解删除目标位置数据的函数,有了前面讲解的头删,相信大家可以想到这里依然可以用直接覆盖的方法进行删除。
void SLErase(SL* ps, int pos)
{
assert(ps);
assert(pos >= 0&&pos < ps->size);
int begin = pos + 1;
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
begin++;
}
ps->size--;
}
所以这部分代码与头删的思想很类似的,只不过改成了从目标位置的下一个开始给前一个数据赋值,一直到最后一个数据赋值完,在此不再过多阐述。
2.2.10 顺序表查找
- 终于到最后一个功能了,用来查找顺序表的数据在其中第几个位置,所以返回类型为整型。
int SLFind(SL* ps, SLDataType x)
{
assert(ps);
int i = 0;
for (i = 0; i < ps->size; i++)
{
if (ps->a[i] == x)
{
return i;
}
}
return -1;
}
这个很简单,只需要利用for循环遍历一遍顺序表即可,然后返回查找数据对应的是第几个就可以了,它可以与其他功能混用,删除数据等等,大家可以自己尝试写一写。
2.3 顺序表的缺点
问题:
- 中间/头删/头插N个数据的时间复杂度:o(N^2),尾删/尾插N个数据的时间复杂度:o(N)
- 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
- 增容一般是呈2倍的增长,会导致有一定的空间浪费。
- 例如当容量为50,满了以后增容到 100,继续插入了1个数据,后面没有数据插入了,那么就浪费了49个数据空间。
为了节省空间和加快效率,我们引出了一种新的结构-----链表,将在下一篇文章为大家讲解,因为链表的类别还挺多,本来想和顺序表一块讲的,但为了避免篇幅过长,所以暂时先搁置一下,大家可以先加深对顺序表的结构理解,自己实现一次顺序表的模拟实现,也对我们之后学习链表有很大的帮助。
总结
我们今天基本讲解了关于顺序表的数据结构以及动态顺序表的代码实现,希望大家可以多多支持(一键三连),欲知后事如何,且看下回链表讲解。
see you~