本篇博客会讲解顺序表这种数据结构的相关知识,并且使用C语言实现一个顺序表。
概述
什么是顺序表呢?顺序表是一种线性的数据结构,其特点是:数据是从第一个位置开始,连续存放的。其实,你完全可以把它等价于C语言中的数组。
如果我们想用C语言实现一个顺序表,当然可以直接定义一个静态的数组,比如:int arr[100];
。此时,这个“顺序表”就可以存储100个整型数据。但是这样的顺序表其实有很多问题。比如,你怎么知道这个顺序表中有效数据有几个呢?所以,需要定义一个结构体,用size来记录顺序表中有效数据的个数,在插入或者删除元素后,记得更新size。
struct SeqList
{
int arr[100];
int size;
};
优化一下:为了以后维护方便,把顺序表的容量100和元素类型int都重新定义一下。为了书写方便,对结构体也typedef一下。
// 顺序表容量
#define N 100
// 顺序表存储元素的数据类型
typedef int SLDataType;
typedef struct SeqList
{
SLDataType arr[N]; // 存储数据的数组
int size; // 有效数据个数
}SeqList;
这就是静态的顺序表的简单定义。但是,这样的顺序表有一个问题:如果数据太多,会存不下;如果数据太少,会造成空间浪费。所以,最好使用动态内存管理的方式,动态地调整数组的大小,用一个指针来记录数组的起始位置。由于数组会动态扩容的,还需要使用capacity来记录此时的容量大小。
// 顺序表存储元素的数据类型
typedef int SLDateType;
// 顺序表
typedef struct SeqList
{
SLDateType* a; // 指向数组的指针
int size; // 有效数据的个数
int capacity; // 容量,最多存储的数据个数
}SeqList;
初始化
先实现一个初始化函数,声明如下:
void SeqListInit(SeqList* ps);
初始化时,可以默认先用malloc开一块空间,同时初始化size和capacity。
注意:函数内改变结构体,必须使用结构体的指针,此时这个指针必须指向一块有效的空间,所以可以断言一下。
void SeqListInit(SeqList* ps)
{
assert(ps);
// 先开辟4个数据的空间
ps->a = (SLDateType*)malloc(sizeof(SLDateType) * 4);
// 检查是否开辟成功
if (ps->a == NULL)
{
// 开辟失败
perror("malloc fail");
return;
}
// 开辟成功
ps->capacity = 4;
ps->size = 0;
}
销毁
接下来实现一个函数来销毁顺序表,声明如下:
void SeqListDestroy(SeqList* ps);
销毁时,需要使用free释放掉动态开辟的空间,防止内存泄漏。
void SeqListDestroy(SeqList* ps)
{
assert(ps);
// 释放内存空间
free(ps->a);
ps->a = NULL;
ps->size = 0;
ps->capacity = 0;
}
打印
顺序表中的数据最好能够打印,方便后续的测试。声明一个打印函数:
void SeqListPrint(SeqList* ps);
打印顺序表中的数据,可以使用for循环遍历数组,结束条件由size来控制。
void SeqListPrint(SeqList* ps)
{
assert(ps);
// 遍历数组并打印
for (int i = 0; i < ps->size; ++i)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
插入
接下来是重头戏,实现一个函数,能够在下标为pos的位置插入一个值x。声明如下:
void SeqListInsert(SeqList* ps, int pos, SLDateType x);
函数实现时,需要注意检查pos的有效性。pos必须满足pos>=0 && pos<=ps->size
,为什么可以等于size呢?如果等于size,其实就是在顺序表的尾部插入数据,并不违反顺序表中数据连续存放的规则。
如何在pos下标插入一个x呢?由于顺序表中的数据是连续存储的,所以需要先把pos及它后面的数据都向后挪动一格,然后再插入数据。
此时我们可以使用memmove来拷贝重叠的内存块。接下来需要计算清楚需要挪动几个数据。假设顺序表中有6个元素,那么size就是6,再假设pos是3,顺序表中存储的是[1 2 3 4 5 6],也就是说,我们要在4前面插入一个x,就需要把[4 5 6]这3个元素向后挪动一格再插入。如何算出要挪动的数据个数呢?size-pos=6-3=3,即可。
但是还要考虑一个问题:如果size==capacity
,就说明顺序表已经放满了,此时就要先扩容。这点我们留给函数SeqListCheckCapacity
来实现。
void SeqListInsert(SeqList* ps, int pos, SLDateType x)
{
assert(ps);
// pos必须在[0, size]之间,如果等于size就是尾插
assert(pos >= 0 && pos <= ps->size);
// 检查容量,如果满了就扩容
SeqListCheckCapacity(ps);
// 挪动数据,腾出位置
// 把pos~size-1的数据向后挪动一格
memmove(ps->a + pos + 1, ps->a + pos, sizeof(SLDateType) * (ps->size - pos));
// 在pos位置插入数据x
ps->a[pos] = x;
// 更新size
ps->size++;
}
接下来谈谈如何检查容量并扩容。以下是函数声明:
void SeqListCheckCapacity(SeqList* ps);
如果ps->size==ps->capacity
,就说明顺序表已经放满了,需要扩容。此时需要对指针a进行realloc,假设把这块动态开辟的空间扩容为原来的2倍,扩容完后还需要记得更新capacity。
void SeqListCheckCapacity(SeqList* ps)
{
assert(ps);
// 判断是否已满
if (ps->size == ps->capacity)
{
// 满了,要扩容
// 扩容成原来的2倍
SLDateType* tmp = (SLDateType*)realloc(ps->a, sizeof(SLDateType) * ps->capacity * 2);
// 检查扩容是否成功
if (tmp == NULL)
{
// 扩容失败
perror("realloc fail");
return;
}
else
{
// 扩容成功
ps->a = tmp;
// 更新capacity
ps->capacity *= 2;
}
}
}
有了Insert函数,就可以实现尾插(在顺序表尾部插入数据)和头插(在顺序表头部插入数据),直接复用即可。
void SeqListPushBack(SeqList* ps, SLDateType x)
{
assert(ps);
// 在size的位置插入x
SeqListInsert(ps, ps->size, x);
}
void SeqListPushFront(SeqList* ps, SLDateType x)
{
assert(ps);
// 在0下标插入x
SeqListInsert(ps, 0, x);
}
删除
接下来实现一个函数,来删除下标为pos位置的数据。以下是函数声明:
void SeqListErase(SeqList* ps, int pos);
对pos的检查和Insert函数有所不同,此时pos不能等于size,因为size位置是最后一个有效数据的下一个位置,已经越界了。
删除的逻辑是:把pos位置后面的数据(不包括pos)向前挪动一格,覆盖掉pos位置的数据。那么有多少个数据呢?比前面Insert函数挪动的数据少一个,因为不包含pos位置,要挪动的数据个数是size-pos-1。
void SeqListErase(SeqList* ps, int pos)
{
assert(ps);
// pos必须在[0, size)之间,size已经越界了
assert(pos >= 0 && pos < ps->size); // 同时保证了size>0
// 挪动数据,覆盖掉pos
// 把pos+1~size-1的数据向前挪一格
memmove(ps->a + pos, ps->a + pos + 1, sizeof(SLDateType) * (ps->size - pos - 1));
// 更新size
ps->size--;
}
有了Erase函数,就可以实现尾删(删除顺序表尾部的数据)和头删(删除顺序表头部的数据),直接复用即可。
void SeqListPopFront(SeqList* ps)
{
assert(ps);
// 检查顺序表是否还有数据
assert(ps->size > 0);
// 删除0下标的数据
SeqListErase(ps, 0);
}
void SeqListPopBack(SeqList* ps)
{
assert(ps);
// 检查顺序表是否还有数据
assert(ps->size > 0);
// 删除size-1位置的数据
SeqListErase(ps, ps->size - 1);
}
查找
查找就简单了。函数声明如下:
int SeqListFind(SeqList* ps, SLDateType x);
假设找到了返回下标,找不到返回-1,遍历数组并且依次比对即可。
int SeqListFind(SeqList* ps, SLDateType x)
{
assert(ps);
// 遍历数组,查找x
for (int i = 0; i < ps->size; ++i)
{
if (ps->a[i] == x)
{
// 找到了
return i;
}
}
// 没找到
return -1;
}
修改
把pos位置的值修改为x,函数声明如下:
void SeqListModify(SeqList* ps, int pos, SLDateType x);
这个就太简单了,直接用下标修改即可。但是不要掉以轻心,检查pos的范围时,pos不能等于size,因为size是最后一个有效数据的下一个位置,已经越界了。
void SeqListModify(SeqList* ps, int pos, SLDateType x)
{
assert(ps);
// pos必须在[0, size)之间,size已经越界了
assert(pos >= 0 && pos < ps->size);
// 把pos位置的数据修改成x
ps->a[pos] = x;
}
总结
顺序表满足以下规律:
- 在顺序表的前半部分插入或者删除数据,由于需要挪动大量的数据,效率较低;不过尾插、尾删的效率很高。
- 插入数据时,如果空间不够,需要扩容,而扩容是有代价的。因为realloc函数存在异地扩容的情况,效率较低;除此之外,还可能存在空间的浪费。
- 但是顺序表并不是一无是处,它有一个重要的特征:数据在内存中是连续存放的,所以顺序表支持下标的随机访问,像排序、二分查找等算法必须依赖于这一点。数据连续存放还有一个优点,根据局部性原理,CPU的缓存命中率较高。
感谢大家的阅读!