目录
前言
顺序表作为我们学习的第一个数据结构是相对比较简单的,因为顺序表其实本质就相当于我们在C语言阶段学习的数组,我们知道数组分为静态数组和动态数组,所以对应的,顺序表和分为静态顺序表和动态顺序表,因为静态顺序表存在一定的缺陷,所以我们重点研究的是动态顺序表。
一、顺序表的介绍
顺序表本质就是一个数组,这个数组可以定长可以不定长,它是指其中的数据是以顺序存储的方式进行存储的,不管在物理上还是逻辑上都是连续的,在逻辑上是连续的我们称之为线性表,物理上也是连续存储的我们称之为顺序表,其存储数据必须由数组下标为0开始进行存储,并且需要连续存储。
顺序表的基本模型:
二、顺序表的分类
1、静态顺序表
从上面的图我们可以看出,静态顺序表就是表中的数组是定长数组的顺序表,其大小是不能发生改变的,这种一般不建议进行使用,因为其大小无法满足我们的需求,空间小了,不够用,空间大了,可能出现浪费。
2、动态顺序表(重要)
动态顺序表是指其中的数据域数组的大小可以发生改变的顺序表,这个一般可以根据具体情况进行扩容,一般情况下我们采用的是两倍的速度进行扩容。
三、C语言实现顺序表
1. 数据类型重定义
在使用C语言来实现数据结构的时候,通常需要一个操作,就是将存储的数据类型进行重定义,这个是为了方便后续要存储其他类型的数据的时候,能够方便的进行修改。基本操作如下:
// 对存储的数据类型进行初始化
typedef int SeqListDataType;
2.顺序表的基本结构
#####(1)静态顺序表
其一般会配合宏来控制数组的大小,其大小是静态顺序表的一个缺点,大小确定小了,则顺序表不够用,如果确定大了,则可能导致内存的浪费
(2)动态顺序表
- 基本结构
// 顺序表的结构
struct SeqList
{
SeqListDataType* a; // 数据域
int size;// 记录的是顺序表中的有效数据个数
int capacity; // 容量
};
- a:数据域就是专门存储数据的地方,复杂为数据向操作系统申请空间存放数据
- size:记录的是顺序表中存储的有效数据的个数,其实也可以间接地表示新数据尾插时插入的位置
- capacity:动态地标识顺序表中的容量,以便进行随时扩容
- 结构类型重定义
// 顺序表的结构
typedef struct SeqList
{
SeqListDataType* a; // 数据域
int size;// 记录的是顺序表中的有效数据个数
int capacity; // 容量
}SeqList;
为了后面能够方便地使用顺序表的这个类型,就是使用的时候不用加struct关键字,那么我们通常会对这个结构进行重定义
3、动态顺序表中常见的函数接口
所有的函数需要在头文件中进行申明,在对应的源文件中进行定义实现
- 声明
// 常见函数接口
// 初始化
void SeqListInit(SeqList* ps);
// 销毁顺序表
void SeqListDestroy(SeqList* ps);
// 尾插数据
void SeqListPushBack(SeqList* ps, SeqListDataType val);
// 尾删数据
void SeqListPopBack(SeqList* ps);
// 头插数据
void SeqListPushFront(SeqList* ps, SeqListDataType val);
// 头删数据
void SeqListPopFront(SeqList* ps);
// 查找函数
int SeqListFind(SeqList* ps, SeqListDataType val);
// 在指定位置前插入数据
void SeqListInsert(SeqList* ps, int pos, SeqListDataType val);
// 删除指定位置的数据
void SeqListErase(SeqList* ps, int pos);
// 顺序表中有效数据个数
int SeqListSize(SeqList* ps);
// 顺序表的容量
int SeqListCapacity(SeqList* ps);
- 定义
(1)初始化
// 初始化
void SeqListInit(SeqList* ps)
{
assert(ps);
ps->a = NULL;
ps->size = ps->capacity = 0;
}
顺序表的结构中包含一个指针,所以当我们声明一个顺序表的时候,我们需要对其中的指针进行初始化,防止出现野指针
(2)销毁函数
// 销毁顺序表
void SeqListDestroy(SeqList* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->size = ps->capacity = 0;
}
顺序表中的数据域即动态指针指向的空间是我们从系统申请的,所以当我们不需要使用这个顺序表的时候,我们需要对顺序表中申请的动态数组进行释放
在顺序表中的销毁函数中,重点是要对资源惊进行释放,因为动态的顺序表的数组是通过malloc或者realloc来申请的,因此需要配套free函数进行资源的释放
(3)尾插函数
// 尾插数据
void SeqListPushBack(SeqList* ps, SeqListDataType val)
{
assert(ps);
if (ps->size == ps->capacity)
CheckCapacity(ps);
ps->a[ps->size] = val;
ps->size++;
}
基本逻辑:优先考虑是否需要进行扩容,之后再将数据插入到下标为size的位置,ps->size
指向的是数组中最后一个数据的下一个位置
(4)尾删函数
// 尾删数据
void SeqListPopBack(SeqList* ps)
{
assert(ps);
assert(ps->size > 0);
ps->size--;
}
基本逻辑:只需要让顺序表标识的有效数据个数-1即可,在进行-1的时候需要检查有效数据个数是否>0,即检查顺序表中是否存在数据可以供删除
(5)头插函数
// 头插数据
void SeqListPushFront(SeqList* ps, SeqListDataType val)
{
assert(ps);
if (ps->size == ps->capacity)
CheckCapacity(ps);
int end = ps->size;// 标识的是最后一个数据的下一个位置
while (end > 0)
{
ps->a[end] = ps->a[end - 1];
end--;
}
// end = 0;
ps->a[end] = val;
ps->size++;
}
基本逻辑:检查是否需要进行扩容,挪动数据(从后往前挪),将数据插入到顺序表中,注意挪动数据的时候需要从后往前挪,防止数据被覆盖,我们可以考虑从最后一个数据开始,依次将数据向后移动一个位置,直到将第一位置的数据移动到第二个数据为止,最后将新数据插入第一个位置。
(6)头删函数
// 头删数据
void SeqListPopFront(SeqList* ps)
{
assert(ps);
int begin = 1;
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
begin++;
}
ps->size--;
}
** 基本逻辑:先从第二个位置开始将数据一次往前挪,将要删除的第一个位置的数据进行覆盖,再将顺序表中的有效数据个数-1,进行删除一定要检查顺序表中是否存在数据,即psl->size>0
**
(7)扩容函数
// 检查容量
void CheckCapacity(SeqList* ps)
{
// 检查容量
// 满了
int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
ps->a = (SeqListDataType*)realloc(ps->a, sizeof(SeqListDataType) * newCapacity);
if (ps->a == NULL)
{
printf("realloc fail\n");
return;
}
// 申请空间成功
ps->capacity = newCapacity;
}
基本逻辑:先确定扩容后的新容量,我们这里是默认扩成原来的两倍,所以这里需要特别注意,一定要检查原来的容量是否为0,利用realloc函数进行扩容的时候有两种情况(原地扩容和异地扩容),将新申请的空间交给原来顺序表的数据域,更新顺序表中的容量
简单介绍一些realloc函数:
注意:realloc函数在进行扩容的时候会检查原来空间后面的空间内存是否满足新大小,如果满足,则进行原地扩容,如果不满足,则进行异地扩容。异地扩容会将原来空间的数据全部搬运到新空间上,因此此过程的效率会比较低
(8)打印顺序表
// 打印顺序表
void SeqListPrint(SeqList* ps)
{
assert(ps);
int i = 0;
for (i = 0; i < ps->size; i++)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
打印顺序表的本质就是遍历顺序表,遍历顺序表的方法是使用一个循环+下标去遍历顺序表
(9)查找函数
int SeqListFind(SeqList* ps, SeqListDataType val)
{
assert(ps);
int i = 0;
for (i = 0; i < ps->size; i++)
{
if (ps->a[i] == val)
{
return i;
}
i++;
}
return -1;
}
这个函数的作用一般是查找某个指定的值的位置(下标),然后和后面的指定在某个位置插入元素和删除元素的函数进行联合使用。
(10)在指定位置前插入数据
// 在指定位置前插入数据
void SeqListInsert(SeqList* ps, int pos, SeqListDataType val)
{
assert(ps);
if(ps->size == ps->capacity)
CheckCapacity(ps);
int end = ps->size;
while (end > pos)
{
ps->a[end] = ps->a[end - 1];
end--;
}
ps->a[pos] = val;
ps->size++;
}
使用查找函数找到对应元素的位置之后再调用此函数,此函数定义的基本逻辑:先检查顺序表是否需要进行扩容,如果需要扩容,则进行扩容,如果不需要扩容,则啥事都不做。挪动数据,注意此时数据应该从后往前挪动,防止顺序表中的数据被覆盖,挪动的原则和头插是挪动类似。
(11)删除指定位置的数据
// 删除指定位置的数据
void SeqListErase(SeqList* ps, int pos)
{
assert(ps);
int begin = pos;
while (begin < ps->size)
{
ps->a[begin] = ps->a[begin + 1];
begin++;
}
ps->size--;
}
在删除前应该检查顺序表是否为空,如果为空,则不需要进行删除,如果不为空,才执行下面代码。同样需要挪动数据,此时的挪动数据的目的就是为了覆盖删除的数据,所以挪动的方向是从前往后,直到将最后一个数据往前挪动一位为止。
(11)顺序表中有效数据个数
// 顺序表中有效数据个数
int SeqListSize(SeqList* ps)
{
assert(ps);
return ps->size;
}
(12)顺序表的容量
// 顺序表的容量
int SeqListCapacity(SeqList* ps)
{
assert(ps);
return ps->capacity;
}
四、顺序表的优缺点
1、优点
在进行数据的查找和尾插尾删的时候可以做到随机访问,时间复杂度为O(1)
2、 缺点
在进行头插和头删的时候需要挪动数据,因此此时效率比较低下,需要进行头插和头删的操作一般不建议使用顺序表
五、测试实验
- 测试尾插和尾删
代码逻辑:
void Test_SeqList1()
{
SeqList sl;
// 这里我们需要注意:当创建一个结构体链表之后需要记得对结构体初始化
SeqListInit(&sl);
// 测试尾插的逻辑
SeqListPushBack(&sl, 1);
SeqListPushBack(&sl, 2);
SeqListPushBack(&sl, 3);
SeqListPushBack(&sl, 4);
SeqListPushBack(&sl, 5);
SeqListPrint(&sl);
// 尾删
SeqListPopBack(&sl);
SeqListPrint(&sl);
SeqListPopBack(&sl);
SeqListPrint(&sl);
SeqListPopBack(&sl);
SeqListPrint(&sl);
SeqListPopBack(&sl);
SeqListPrint(&sl);
SeqListPopBack(&sl);
SeqListPrint(&sl);
}
结果:
- 测试Insert函数接口
代码逻辑:
void Test_SeqList2()
{
SeqList sl;
SeqListInit(&sl);
SeqListPushBack(&sl, 1);
SeqListPushBack(&sl, 2);
SeqListPushBack(&sl, 3);
SeqListPushBack(&sl, 4);
SeqListPrint(&sl);
// 在pos位置插入数据
SeqListInsert(&sl, 2, 5);
SeqListPrint(&sl);
SeqListInsert(&sl, 0, 6);
SeqListPrint(&sl);
SeqListInsert(&sl, 0, 7);
SeqListPrint(&sl);
// SeqListInsert(&sl, 100, 100);// 这个会报错,超出插入的范围
SeqListPrint(&sl);
}
结果:
- 测试头插和头删
代码逻辑:
void Test_SeqList3()
{
SeqList sl;
SeqListInit(&sl);
// 头插
SeqListPushFront(&sl, 1);
SeqListPushFront(&sl, 2);
SeqListPushFront(&sl, 3);
// 3 2 1
SeqListPrint(&sl);
// 头删
SeqListPopFront(&sl);
SeqListPrint(&sl);
SeqListPopFront(&sl);
SeqListPrint(&sl);
SeqListPopFront(&sl);
SeqListPrint(&sl);
}
结果:
六、关于顺序表的代码实现中的一些细节
- 函数接口中传顺序表总是传指针?
如果只是传结构体对象的话,由于两个结构体的对象是处于两个不同的栈帧,因此修改其中一个结构体对象的指不会影响另一个结构体对象的值,传指针的话也能够在很大的程度上减少拷贝 - 在函数的实现中为什么需要对顺序表的指针进行断言检查?
在顺序表中常见的函数接口中,经常需要访问到顺序表中的内容,如果此时用户不小心传了一个空指针进来,如果我们没有对该指针进行断言检查,那么在函数的实现中就会出现对空指针进行解引用(访问空指针指向的空间),此时就会造成程序崩溃 - 在插入函数中需要注意的点
凡是进行插入,一定要提前检查是否需要进行扩容,尾插不需要挪动数据,在其他位置插入都需要挪动数据,在实现在pos位置插入数据的Insert函数接口一定要注意pos为0的情况,此时需要进行特殊处理,如果插入函数需要挪动数据,注意防止数据被覆盖,即挪动数据的方向为:从后往前 - 在删除函数中需要注意的点
凡是进行删除,一定要检查顺序表中是否存在有效数据,即psl->size>0是否成立,如果忽略这一步,就会很容易造成后续的数组越界,在删除函数中如果需要挪动数据,注意知道删除函数中挪动数据的目的就是为了覆盖删除的数据,所以挪动方向为从前往后。