本章目标
- 线性表
- 顺序表
1.线性表
线性表是具有n个具有相同元素的有限序列。线性表是一种实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串……
心爱星标在逻辑上是线性结构,也就说是连续的一条直线。但在物理结构上不一定是连续的,线性表在物理上存储时,通常以数组和链式结构的形式存储。
2.顺序表
2.1概念及结构
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储(简单地说,顺序表就是一个数组,与数组的唯一区别是,要求是连续的,只能从头开始,连续存储),在数组上完成数据的增删查改。
顺序表一般可以分为:
- 静态顺序表:使用定长数组存储元素。
静态的顺序表是定长的,当我们不知道到底需要多少,N给小了不够用,给大了浪费,具有很低的实践意义
2. 动态顺序表:使用动态开辟数组存储。
我们可以使用malloc
等函数实现动态内存开辟,内存更可控一些,但仍会有一些浪费。
扩容会有一定代价,在扩容时数据结构并未明确定义,而是根据实际情况来灵活进行,具体情况具体分析。一般情况下,喜欢用2倍扩容(相对合适的量)。
2.2接口实现
静态顺序表只适用于确定知道需要存放多少数据的场景。静态顺序表的定长数组导致N定大了,空间开多了浪费,开少了不够用。所以现实中基本都是使用动态顺序表,根据需要动态的分配空间大小,所以下面我们实现动态顺序表。
typedef int SLDataType;
// 顺序表的动态存储
typedef struct SeqList
{
SLDataType* array; // 指向动态开辟的数组
size_t size ; // 有效数据个数
size_t capicity ; // 容量空间的大小
}SeqList;
// 基本增删查改接口
// 顺序表初始
void SeqListInit(SeqList* psl);
// 检查空间,如果满了,进行增容
void CheckCapacity(SeqList* psl);
// 顺序表尾插
void SeqListPushBack(SeqList* psl, SLDataType x);
// 顺序表尾删
void SeqListPopBack(SeqList* psl);
// 顺序表头插
void SeqListPushFront(SeqList* psl, SLDataType x);
// 顺序表头删
void SeqListPopFront(SeqList* psl);
// 顺序表查找
int SeqListFind(SeqList* psl, SLDataType x);
// 顺序表在pos位置插入x
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x);
// 顺序表删除pos位置的值
void SeqListErase(SeqList* psl, size_t pos);
// 顺序表销毁
void SeqListDestory(SeqList* psl);
// 顺序表打印
void SeqListPrint(SeqList* psl);
- 注意:
- 在传参时,一定要注意传的是地址
- 名字可以随便取名,但最好有逻辑性
几个核心接口的使用:
//1.初始化
void SeqListInit(SeqList* psl)
{
psl->a = NULL;//可以在这开空间,后边在扩容,也可以不开
psl->size = 0;
psl->capacity = 0;
}
//2.销毁
void SeqListDestory(SeqList* psl);
{
if(psl->a != NULL)
{
free(psl->a);
psl->a = NULL;
psl->size = 0;
psl->capacity = 0;
}
}
//3.尾插
void SeqListPushBack(SeqList* psl, SLDataType x)
{
//要先考虑空间够不够的问题
if(psl->size == psl->capacity)
{
int newCapacity = psl->capacity == 0 ? 4 : psl->capacity * 2;
SLDataType* tmp = realloc(psl->a,sizeof(SLDataType) * newCapacity);
if(tmp = NULL)
{
perror("realloc faile");//内存不足提示
return;
}
//为什么没有直接将开辟的空间给a,而是用了tmp?
//防止开辟空间失败,覆盖掉以前的空间
psl->a = tmp;
psl->capacity = newCapzcity;
}
psl->a[psl->size] = x;
psl->size++;
}
//4.尾删
void SeqListPopBack(SeqList* psl)
{
//空
//做一个温柔的检查
if(psl->size == 0)
{
return;
}
//暴力检查
assert(psl->size > 0);
psl->size--;
//直接减,有效的就只剩前面的了
}
//5.头插
void SeqListPushFront(SeqList* psl, SLDataType x)
{
SLCheckCapacity(sql);
//挪动数据
int end = psl->size - 1;
while(end >= 0)
{
psl->a[end + 1] = psl->a[end];
--end;
}
psl->a[0] = x;
psl->size++;//长度++
}
//6.头删
void SeqListPopFront(SeqList* psl)
{
assert(psl->size > 0);
int begin = 1;
while(begin < psl ->size)
{
psl->a[begin-1] = psl->a[begin];
++begin;
}
psl->size--;
}
//7.在任意下标位置插入
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x)
{
assert(psl);
assert(pos >= 0 && pos <= psl->size);
//顺序表是从0位置开始连续的,pos要检查
//挪动数据
int end = size - 1;
while(end >= pos)
{
psl->a[end] = psl->a[end+1];
--end;
}
psl->a[pos] = x;
psl->size++;
}
//8.在任意下标位置删除
void SeqListErase(SeqList* psl, size_t pos)
{
assert(psl);
assert(pos >= 0 && pos < psl->size);
//挪动覆盖
int begin = pos + 1;
while(begin < psl->size)
{
psl->a[begin - 1] = psl->a[begin];
++begin;
}
psl->size--;
}
//9.查找
int SeqListFind(SeqList* psl, SLDataType x)
{
assert(psl);
for(int i = 0;i < psl->size;i++)
{
if(psl->a[i] == x)
{
return i;
}
}
return -1;
}
注意事项:
1.对于初始化:
- 在顺序表被初始化(即
SeqListInit
函数被调用)时,a
通常被设置为NULL
,这表示当前没有为顺序表分配任何存储空间。此时,顺序表的size
(元素数量)和capacity
(容量)都被设置为0。 - 当需要向顺序表中添加元素,并且当前分配的存储空间不足以容纳更多元素时,顺序表会进行扩容操作。扩容操作通常涉及以下几个步骤:
- 分配一个新的、更大的内存区域来存储元素。
- 将旧内存区域中的元素复制到新内存区域中(如果需要的话,还可能包括一些额外的初始化或调整操作)。
- 更新
a
指针,使其指向新的内存区域。 - 更新
capacity
值,以反映新的容量。 - 在新内存区域的适当位置添加新元素,并更新
size
值。
因此,a
并不总是“指向初始空间”,而是在顺序表的整个生命周期中,根据需要指向不同大小的内存区域。这些内存区域可能是通过多次扩容操作获得的,也可能是顺序表初始化后直接分配的一个足够大的区域(这取决于顺序表的实现细节和预期用途)。
重要的是要理解,a
指针的作用是提供对顺序表存储空间的访问,而顺序表的扩容操作是通过改变a
的指向来实现的。
- 更新
2.对于尾插:
- 我们首先要知道,
realloc
是可以对空的地址进行扩容的,其作用就相当于malloc
函数,开辟一块新的空间。 - 设置
tmp
的意义是:防止开辟空间失败,覆盖掉以前的空间。
3.对于尾删:
- 直接
size--
就可以了,直接减掉后,前面的才是有效部分。 - 后面剪掉的部分是无效的,不管我们再进行头插或是尾插,其都被直接覆盖了
- 后面尾删的空间需不需要
free
- 不能释放,内存的申请不支持分期free,要释放就只能整体释放
- 空间是可以重复利用的,况且,单独释放了以后,空间就不连续了,而顺序表的要求必须是连续的
- 对于是否减到空的问题可以用检查来避免,如上述代码段所示进行温柔的检查,或者暴力检查,两者二选一。
4.对于头插:
- 顺序表是从头开始连续储存的,所以我们并不能在前面直接插入新的空间,而是要将空间开辟好后,将原有的空间向后移,然后在进行插入
- 顺序表不要频繁的调用头插,时间复杂度为O(N^2)
- 链表是可以直接前插的
5.对于头删:
- 在挪动时,只能从前往后挪,不能从后往前挪
6.关于越界:
- 越界读在C语言中一般不会报错
- 越界写可能会报错,越界的检查是一种抽查行为,设置一些标记位置进行查看,就像查酒驾一样
7.在任意的下标位置插入
- 这个任意的意思就是,只要符合顺序表的要求在哪都可以插入
- 一定要对
pos
进行检查,以满足顺序表从0开始且连续的特性 - 当
pos
=size
时,就相当于直接尾插了
2.3数组相关面试
- 原地移除数组中所有的元素val,要求时间复杂度为O(N),空间复杂度为O(1)。27. 移除元素 - 力扣(LeetCode)
- 删除排序数组中的重复项。26. 删除有序数组中的重复项 - 力扣(LeetCode)
- 合并两个有序数组。88. 合并两个有序数组 - 力扣(LeetCode)
- 习题详细解答见下一篇习题详解
2.4顺序表的问题及思考
问题:
- 中间/头部的插入删除,时间复杂度为O(N)
- 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
- 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到 200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
思考:如何解决以上问题呢?下一篇文章会给出链表的具体介绍,从中解答