文章细分了各个知识点,可在目录中快速跳转。
手机端用户在查看代码块时建议点击代码右上角放大查看,每一段代码均有完整注释。
在众多数据结构中,顺序表和链表是最基础最简单的,但同时也是最实用的,初学者以此开始数据结构的学习是相当合适的,下面我们就来介绍一下顺序表。
说起顺序表,我们先来了解一下线性表。线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串… 也就是说顺序表是线性表的一种形式。
线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,线性表在物理上存储时,通常以数组和链式结构的形式存储。
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构。其本质是一种结构体,在结构体中放置一个动态大小的数组进行存储数据,数据的增删查改。由于物理地址连续,因此是从头开始依次存储。就像我们喝饮料一般都是关心饮料好不好喝,而不是关注饮料瓶好不好看,则可以认为顺序表就是从头开始依次存储的数组。
顺序表一般可以分为两类,静态顺序表和动态顺序表。静态顺序表就是顺序表内空间不可变,而动态顺序表就是表内空间可变,同理,我们将其理解为空间不可变与可变的数组即可。
在实际中,由于大多数情况我们在创建顺序表之前并不知道具体需要多大的空间,初始化时空间给少了会不够用,给多了会造成浪费,故多数情况我们使用动态顺序表,下面的接口实现也是以实现动态顺序表为主。
typedef int SLDataType; //注释1
typedef struct SeqList
{
SLDataType* a; //指向动态开辟的数组的指针
size_t size; //存储数据的有效个数
size_t capacity; //空间容量
}SL; //注释2
注释:
由于后期我们可能会改变结构体存储数据的类型,而一但更改,我们需要对每一个调用该类型的地方进行修改,十分麻烦,使用typedef对数据的类型进行重命名,这样以后要更换类型只需要更改此处就可以达到全文替换的目的。
重命名简便后续输入,注意我们为什么不直接命名简便一点呢?每个命名都是基于英文单词的释义,这样可以增加在多人协作,以及后续维护代码时的可读性。
void SLInit(SL* psl)
{
assert(psl); //对指针进行断言,防止出现野指针,本文其余位置的assert同理,不再解释
psl->a = NULL; //此时我们还未开始开辟动态空间,先置为空指针
psl->size = 0; //初始化有效个数为0
psl->capacity = 0; //初始化空间容量为0
}
注:我们在创建和初始化时,并没有给动态数组开辟空间,只是定义了对数组进行修改所需的参数,我们将在扩容时真正实现空间的开辟和修改。
void SLDestory(SL* psl)
{
assert(psl);
if (psl->a != NULL)
{
free(psl->a); //与初始化相比,只多了此步
//释放动态开辟空间并置空指针
psl->a = NULL;
psl->size = 0;
psl->capacity = 0;
}
}
void SLCheckCapacity(SL* psl)
{
assert(psl);
if (psl->size == psl->capacity) //当有效元素个数=空间容量时开始扩容(开辟)
{
size_t newCapacity = psl->capacity == 0 ? 4 : psl->capacity * 2;
//定义新空间大小,这里使用了三目操作符,意思是当原空间容量为0时,开辟空间容量为4,
//原空间不为0时进行扩容,新空间容量扩容为原空间的2倍。
SLDataType* tmp = (SLDataType*)realloc(psl->a, sizeof(SLDataType) * newCapacity);
//realloc更改空间大小
if (tmp == NULL)
{
perror("realloc"); //当开辟失败时报错
return;
}
psl->a = tmp; //将原指针指向新开辟的空间
psl->capacity = newCapacity; //更新空间容量大小
}
}
void SLPushBack(SL* psl, SLDataType x) //x为要插入的数据
{
assert(psl);
SLCheckCapacity(psl);
psl->a[psl->size] = x; //直接将要插入的数据插入到尾部后
psl->size++; //更新有效数据个数
}
void SLPushFront(SL* psl, SLDataType x)
{
assert(psl);
SLCheckCapacity(psl);
int end = psl->size - 1;
while (end >= 0) //将所有元素全部向后挪动一位
{
psl->a[end + 1] = psl->a[end]; //从末尾开始向前,
//将当前项覆盖后一位
end--;
}
psl->a[0] = x; //插入元素
psl->size++; //更新有效元素个数
}
注:数组的首元素地址是固定的,代表整个数组的地址,不能往前扩容,只能将所有元素向后挪动一位,空出位置给要插入的元素。
void SLPopBack(SL* psl)
{
assert(psl);
assert(psl->size > 0); //数组删到0为止,防止删过头
psl->size--; //更新有效元素个数
}
注:我们要理解size(有效元素个数)以及capacity(空间容量)的实质,我们使用这两个变量来描述顺序表,但修改其本身是不会直接对顺序表造成影响的,而是通过其他函数调用这两个参数对顺序表修改。
如capacity在检查空间容量函数中使用,size也是同理,我们在后续操作中访问顺序表时,是不访问超过size个数的元素的,以此达成“删除”元素的目的。但同时我们应该也注意通过动态开辟的空间是不支持“分期还款”的,即不能只释放开辟的一部分空间,实际上被“删除”的部分在空间中仍然存在,但我们已经不再使用。
void SLPopFront(SL* psl)
{
assert(psl);
assert(psl->size > 0);
int begin = 1;
while (begin <= (psl->size - 1)) //与头插类似,将所有元素向前移动一位
{
psl->a[begin - 1] = psl->a[begin];//从前往后,将当前项覆盖前一位
begin++;
}
psl->size--; //更新有效元素个数
}
void SLInsert(SL* psl, int pos, SLDataType x) //pos为下标
{
assert(psl);
assert(pos >= 0 && pos <= psl->size); //保证下标不越界
SLCheckCapacity(psl);
int end = psl->size-1;
while (end >=pos) //与头插类似,只不过改成从末尾往
{ //pos项为止,将当前项覆盖后一项
psl->a[end + 1] = psl->a[end];
end--;
}
psl->size++;
}
注:类似于头插,只不过将pos项后的元素向后移动一位,可参考头插配图理解。
void SLErase(SL* psl, int pos, SLDataType x)
{
assert(psl);
assert(pos >= 0 && pos <= psl->size - 1); //限制下标,注意不能
//删除未开辟的空间
int begin = pos + 1;
while (begin <= psl->size - 1) //类似于头删,从pos项后一位开始,
{ //将当前项覆盖前一项
psl->a[begin - 1] = psl->a[begin];
begin++;
}
psl->size--;
}
注:类似于头删,将pos项后的所有元素向前移动一位。可参考头删配图理解。
void SLPrint(SL* psl)
{
assert(psl);
for (int i = 0; i < psl->size; i++)
{
printf("%d ", psl->a[i]);
}
printf("\n");
}
- 头删,头插,指定插入,指定删除时间复杂度为O(N),每次都需要挪动元素,效率低下。
- 动态扩容时,如果当前空间的剩余部分不足以满足需求,会直接开辟新的一块完整的空间存放所有数据,旧空间会被废弃,造成浪费。详请参考C语言的动态空间开辟部分,不再赘述。
- 扩容扩多了会浪费,扩少了会不够用,势必会造成空间浪费。
- 物理空间连续,可以通过下标随意访问空间内元素
可以看到顺序表的缺点比较多,而原因是因为物理空间连续有利也有弊。下篇文章博主将介绍物理空间不连续的链表,以弥补其弊端。
知识逻辑框架可看文章开头的目录。
本文的每一段代码都带有注释,对于难懂点使用格外标注以及图表方式辅助理解,如果对你有所帮助,还望点赞收藏支持博主。
文章中有什么不对的丶可改正的丶可优化的地方,欢迎各位来评论区指点交流,博主看到后会一一回复。