完整代码链接:DataStructure: 基本数据结构的实现。 (gitee.com)
目录
一、顺序表的本质:
顺序表是一种线性表,它的物理结构和逻辑结构都是线性且连续的,其一般采用数组实现,所以其本质就是一个数组。
二、顺序表的类型:
顺序表一般可分为:
1.静态顺序表:数组长度固定;
2.动态顺序表:数组长度可动态增长。
三、静态顺序表的结构体实现:
typedef int SLDateType;//用于表示这个顺序表内每一个元素共同的数据类型
#define N 10 //可以让改变顺序表的大小更容易
typedef struct SeqList
{
SLDateType a[N];
int size;
}SeqList;
在上述结构体设计中,我们使用了 typedef 以便于处理后续我们可能改变顺序表内元素类型而引发的操作麻烦问题(如果顺序表内原来是存int型的数据,现在要改为存储float型的了,只需要改typedef 后的 int 为 float 就可以了),并且使用#define来让后续扩充顺序表大小也更加容易。
在这里,a[N]即包含了一个数组的首地址,也包含了这个数组的空间容量大小;
size被用来记录顺序表内有效数据的数量。
但是,这个顺序表的大小是固定的,当这个顺序表容量不够时,还需要我们自己去改,改为多少又是个问题,所以我们需要一个动态顺序表。
四、动态顺序表的结构体实现:
typedef int SLDateType;
typedef struct SeqList
{
SLDateType* a;//指向动态开辟的数组
int size;//顺序表内的有效数据个数
int capacity;//顺序表的空间容量大小
}SeqList;
在这里,我们将数组的首地址和空间大小分离出来,将原来的 [ ] 表现形式改为 * 形式。
这样,我们就可以采用动态开辟的方式来让顺序表的空间容量动态增长了。
五、顺序表的基本接口:
1.初始化:
void SeqListInit(SeqList* ps)
{
//最开始时,顺序表的有效数据与空间容量都为0
ps->a = NULL;
ps->size = 0;
ps->capacity = 0;
}
2.销毁:
void SeqListDestroy(SeqList* ps)
{
free(ps->a);//指针所指向的空间被释放了,但此时指针还指向这段空间
ps->a = NULL;//防止野指针
ps->size = 0;
ps->capacity = 0;
}
3.扩容:
void SeqListCheckCapacity(SeqList* ps)
{
assert(ps);
//如果满了,需要增容
if (ps->size >= ps->capacity)
{
//当顺序表容量为0时
if (ps->capacity == 0)
{
ps->capacity = 1;
}
ps->capacity *= 2;
ps->a = (SLDateType*)realloc(ps->a, sizeof(SLDateType) * ps->capacity);
if (ps->a == NULL)
{
printf("扩容失败\n");
return;
}
}
}
4.打印:
void SeqListPrint(SeqList* ps)
{
assert(ps);
for (int i = 0;i < ps->size;i++)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
5.增加数据:
①:尾插:
void SeqListPushBack(SeqList* ps, SLDateType x)
{
assert(ps);
SeqListCheckCapacity(ps);//不能写&ps,因为ps已经是指针了
ps->a[ps->size] = x;
ps->size++;
}
②:头插:
void SeqListPushFront(SeqList* ps, SLDateType x)
{
assert(ps);
SeqListCheckCapacity(ps);
//从右向左把数都往后移一位
int end = ps->size - 1;
while (end >= 0)
{
ps->a[end + 1] = ps->a[end];
end--;
}
ps->a[0] = x;
ps->size++;
}
假设我们要在头上插入一个 0 :
③:插入:
void SeqListInsert(SeqList* ps, int pos, SLDateType x)
{
assert(ps);
if (pos > ps->size||pos<1)
return;
SeqListCheckCapacity(ps);
//找到目标位置
int end = ps->size;
while (end >= pos)
{
ps->a[end] = ps->a[end-1];
end--;
}
ps->a[end] = x;
ps->size++;
}
假设要在第3个位置插入6,此时,pos在顺序表内会指向第4个元素
6:删除数据:
①:尾删:
void SeqListPopBack(SeqList* ps)
{
assert(ps);
if (ps->size > 0)
{
ps->size--;
}
}
②:头删:
void SeqListPopFront(SeqList* ps)
{
assert(ps);
if (ps->size == 0)
{
printf("没有可以删除的元素\n");
return;
}
//从左往右向前一位覆盖
int begin = 0;
while (begin < ps->size-1)
{
ps->a[begin] = ps->a[begin + 1];
begin++;
}
ps->size--;
}
③:删除:
void SeqListErase(SeqList* ps, int pos)
{
assert(ps);
if (ps->size == 0 || pos<1 || pos>ps->size)
return;
//找到目标位置
int begin = pos - 1;
while (begin < ps->size)
{
ps->a[begin] = ps->a[begin + 1];
begin++;
}
ps->size--;
}
假设要删除第 3 个位置的元素
7:查找数据:
int SeqListFind(SeqList* ps, SLDateType x)
{
assert(ps);
for (int i = 0;i < ps->size;i++)
{
if (ps->a[i] == x)
{
return i;
}
}
return -1;
}
其实我们还可以实现更多的接口,比如排序、二分查找等,主要是看我们的需求是什么。
六、顺序表的优缺点:
缺点:
1、中间或者头部的插入与删除很慢,因为需要挪动数据,时间复杂度为0(N);
2、空间不够时,需要增容,增容会有一定的消耗与空间浪费。
优点:
1、可以随机访问,查找、修改与排序很方便;
2、缓存命中率比较高(相对于链式结构),因为顺序表物理空间是连续的。
七、简单讲解缓存命中率:
当我们要执行一个文件(这里可以看成一个 .c 文件)时,由于内存速度比较慢,所以计算器会将内存里面的文件放入寄存器(如果文件比较小),但是寄存器容量很小(一般为4bytes\8bytes),所以对于数组而言(容量比较大),计算机会把数组放入三级缓存里面。
当计算机需要数据时,会先通过之前拿到的地址(这里指数组首地址)到缓存里面拿数据,看里面有没有,如果有,则命中了,此时就直接用里面的数据;如果没有,就没命中,则需要去内存里面读,然后把它们放到缓存里面,再去缓存读。这样,我们下次要想再访问这些数据,就不需要再去内存里面读了。
假设存在一个顺序表a,我们要对其所有数据+1
此时,计算机会将顺序表内的数据加载到缓存,但是缓存在访问一个数据时,不会只加载一个数据到缓存,而是加载从这个数据开始的一段数据到缓存(预加载)。
这里,我们假设此时会同时加载16bytes到缓存。
这样,我们在执行指令时,就不用每次都去内存里面读数据了,提升了效率。
而链式结构就不一样了,因为链式结构在空间上不一定是连续的,所以当我们加载数据的时候,不一定就把后面的数据加载进去了,所以可能每次都要去内存里面加载,而且这种方式也会导致缓存污染(加载到缓存的数据不是我们想要的数据,却又占据了缓存空间)。