顺序表是一种线性表的存储结构,它采用数组来存储元素,并且保持元素之间的逻辑顺序与物理顺序相同。顺序表具有以下特点:
总的来说,顺序表适用于对元素的随机访问操作较多,但插入和删除操作较少的场景。
下面我们就来一起来了解一下顺序表这个专题。
【百度百科】顺序表是在计算机内存中以数组的形式保存的线性表,线性表的顺序存储是指用一组地址连续的存储单元依次存储线性表中的各个元素、使得线性表中在逻辑结构上相邻的数据元素存储在相邻的物理存储单元中,即通过数据元素物理存储的相邻关系来反映数据元素之间逻辑上的相邻关系,采用顺序存储结构的线性表通常称为顺序表。顺序表是将表中的结点依次存放在计算机内存中一组地址连续的存储单元中。
顺序表一般可以分为静态顺序表、动态顺序表,其实我们所说的顺序表底层就是数组。
此时我们就会考虑,既然已经有了数组了,为什么还会出来顺序表这个概念呢,那这个问题我就没法马上给你答案了,这就要我们在今后的一步步学习过程中发现他的妙处了。
顺序表是线性表的一种,其底层是数组,然后物理结构和逻辑结构一定是连续的。
使用定长数组存储元素
我们在编译时便确定了数组的大小。这就是一个静态顺序表。
使用动态开辟的数组存储,例如malloc,calloc,realloc
这就是两个顺序表的大致区别
静态顺序表只适用于确定知道需要存多少数据的场景。静态顺序表的定长数组导致N定大了,空 间开多了浪费,开少了不够用。
所以现实中基本都是使用动态顺序表,根据需要动态的分配空间 大小,所以下面我们实现动态顺序表。
什么是接口函数?
接口函数是在数据进行操作时进行调用的函数,通过调用数据结构的接口帮助你完成一系列操作
基本增删接口:
void SLInit(SL* ps);//名字简写 初始化 void SLPushBack(SL* ps, SLDatatype x); //尾插 void SLPopBack(SL* ps); //尾删 void SLPushFront(SL* ps, SLDatatype x); //头插 void SLPopFront(SL* ps); //头删 void SLPrit(SL* ps); //打印 void SLDestory(SL* ps); //释放 void SLCheckCapacity(SL* ps); //检查容量-扩容 void SLInsert(SL* ps, int pos, SLDatatype x);//任意位置插入 void SLErase(SL* ps, int pos); //任意位置删除 int SLFind(SL* ps, SLDatatype x); //查找 void SLModify(SL* ps,int pos, SLDatatype x); //修改
下面我们来一一介绍一下这些接口。
为了养成模块化好习惯,我们尽量把代码分开来写。首先打开 VS2022,在解决方案资源管理器中的 "头文件" 文件夹中创建 SeqList.h 用来存放头文件。在 "源文件" 文件夹中创建 SeqList.c 用来实现函数,Test.c 用来测试我们的顺序表:
#pragma once//确保每个头文件只被包含一次
#include <stdlib.h>
#include <stdio.h>
#include <assert.h>
//#define N 200
typedef int SLDatatype;
//静态顺序表
//struct SeqList
//{
// SLDatatype a[N];//静态顺序表,无法改变,N太小,不够用,太大,浪费空间
// int size; //有序数组的个数
//};
//改成动态开辟
typedef struct SeqList
{
SLDatatype* a;//指向动态数组指针
int size; //数据个数
int capacity; //容量-空间大小
}SL;
为了方便后续修改数据类型,我们可以使用 typedef 定义一个新的数据类型,这里我们把它取名为 SLDataType(顺序表数据类型)。 我们为了让定义的结构体使用时更方便,我们同样可以使用 typedef 将其定义为 SL (此时 SL = struct SeqList,后续在使用时可以更加方便)。
首先引入我们自己创建的头文件 #include "SeqList.h" ,我们就可以开始动手实现顺序表初始化函数了。 首先通过 psl 指向 array,将数组为空。因为是初始化,所以将有效数据个数和数组时即能存数据的空间容量一并置为0。
#include "SeqList.h"
//初始化函数
void SLInit(SL* ps)
{
ps->a= NULL;
ps->size = ps->capacity= 0;
}
后续我们会插入元素,如果空间不够,则使用realloc函数扩容。 newcapacitv是扩容后的内存空间,tmp是指向这个·新的空间的指针。如果空间为0,则扩容可以放置4个元素的空间,如果空间已满,则在原来的空间基础上,在增加1倍,这样其实依然是有浪费的,后面我们会解决这个问题。
//检查容量-扩容
void SLCheckCapacity(SL* ps)
{
if (ps->size == ps->capacity)
{
int newcapacitv = ps->capacity == 0 ? 4 : ps->capacity * 2;
SLDatatype* tmp = (SLDatatype*)realloc(ps->a, newcapacitv * sizeof(SLDatatype));
if (tmp == NULL)
{
perror("relloc:");
exit(-1);//结束程序
}
ps->a = tmp;
ps->capacity = newcapacitv;
}
}
尾插就是在最后元素的后面插入一个元素
但是尾插要比较复杂,因为存在几种不同的情况:
尾插有三种情况: ① 第一种情况是顺序表压根就没有空间。 ② 第二种情况就是我们创建的 capacity 空间满了。 ③ 第三种情况是空间足够,直接插入数据即可。
void SLPushBack(SL* ps, SLDatatype x)
{
SLCheckCapacity(ps);//检查容量空间
ps -> a[ps->size] = x;
ps->size++;
}
实现函数后,我们如果想要打印到屏幕上,需要实现打印函数,这样我们每次实现一个功能,测试时,只需调用这个函数就可以了
//打印函数
void SLPrit(SL* ps)
{
for (int i = 0; i < ps->size; i++)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
因为是动态开辟的,所以如果空间不用我们就需要销毁掉。如果不销毁会存在内存泄漏的风险,所以与之对应的我们写一个销毁的接口函数。
void SLDestory(SL* ps)//动态开辟的内存越界使用有时
{ //有时要在free的时候才能检查出来。
if (ps->a)
{
free(ps->a);//pa->!=NULL
ps->a = NULL;
ps->size = ps->capacity = 0;
}
}
即删除最后一个元素,大部分人所想也许是把最后一个元素置为0或者-1,这是可行的,但如果最后一个数就是0呢? 其实我们这里只需要元素数量减去一个就好了,即size--,这样我们就无法访问最后一个元素了,它便是无效的数据。
void SLPopBack(SL* ps)
{
assert(ps->size);
ps->size--;
}
这里有可能会出现如果内存中一个元素都没有了,size有可能减到-1的位置上,这便是越界了,但size是我们用来统计元素数量的,不可能小于0的,所以这里我们需要断言一下。
越界:
越界是不一定报错的,系统对越界的检查是一种设岗抽查。
如同你酒驾了,但没有被交警抓住,但它依然是错误的,所以我们应该避免这种情况。
顺序表要求数据是连续存储的,且必须是从头开始存储。所以,对于顺序表而言如果要实现头插,就需要把数据往后挪动。不能从前往后挪,如果从前往后挪就挪就会把后面的数据覆盖掉。 思路:首先创建一个 end 变量用来指向要移动的数据,因为指向的是数据的下标,所以是 size 要减 1 。随后进入 while 循环,如果 end >= 0 说明还没有移动完,就会进入循环。循环体内利用下标,进行向后移动操作,移动结束后再 end-- ,进行下一个数据的向后移动。挪动数据成功后,就可以插入了。此时顺序表第一个位置就被腾出来了,就可以在下标0位置插入欲插入的数据 x 了。最后记得 size++ 。
void SLPushFront(SL* ps, SLDatatype x)
{
SLCheckCapacity(ps);
//从后向前挪动数据
int end = ps->size - 1;
while (end >= 0)
{
ps->a[end + 1] = ps->a[end];
end--;
}
ps->a[0] = x;
ps ->size++;
}
思路和头插类似,依次向前挪动,然后数量-1即size--
//头删
void SLPopFront(SL* ps)
{
assert(ps->size);
int begin = 1;
while (begin < ps->size)
{
ps->a[begin-1] = ps->a[begin];
begin++;
}
ps->size--;
}
如果头删多次调用,如何内存中已经没有元素了,size依然在减。所以这里依然会出现越界,所以需要断言。
代码思路: 首先添加 pos 位置的限定条件,限定 pos >= 0 并且 pos <= psl->size 从而保证 pos 合法。然后,因为是插入所以免不了要检查增容,直接调用之前写好的检查增容的函数即可。检查完后就可以开始移动了,和头插差不多,我们创建一个变量 end 记录最后一个的下标(psl->size-1),并通过它来指向要移动的数据。最后进入 while 循环,以 end >= pos 作为条件。移动完后,x 的位置就腾出来了,再把 x 插入进去,最后再 size++,就完成了。
//任意插入
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 的位置。限制条件部分和 SeqListInsert 不同的是,因为 psl->size 这个位置没有效数据,所以删除的位置不能是 psl->size!
//任意删除
void SLErase(SL* ps, int pos)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
//注意边界的处理
/*int begin = pos;
while (begin < ps->size - 1)
{
ps->a[begin] = ps->a[begin + 1];
begin++;
}*/
int begin = pos + 1;
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
begin++;
}
ps->size--;
}
//查找
int SLFind(SL* ps, SLDatatype x)
{
assert(ps);
for (int i = 0; i <= ps->size; i++)
{
if (ps->a[i] == x)
return i;
}
return -1;
}
//修改
void SLModify(SL* ps, int pos, SLDatatype x)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
ps->a[pos] = x;
}