一.引言
在编程世界里,数组(Array)是我们最常用的数据结构之一。它简单、高效,通过下标就能快速访问任何一个元素。但是,数组也有它的“硬伤”:一旦定义,大小就固定了,无法轻易改变。如果需要存储的数据量超出了数组的容量,程序就会崩溃;如果存储的数据量远小于数组容量,又会造成内存的浪费。
更令人头疼的是,数组天生不擅长进行中间位置的插入和删除操作。比如要在数组中间插入一个元素,你不得不将它后面的所有元素都向后移动一位,为新元素腾出空间;同理,删除一个元素后,后面的所有元素也需要向前移动来填补空缺。这些操作不仅繁琐,而且效率低下。
那么,有没有一种数据结构,既能保留数组的优点,又能克服这些缺点呢?答案就是——顺序表(Sequential List)。
二.什么是顺序表?
顺序表(Sequential List)是一种基于数组实现的数据结构。我们重点关注其逻辑结构和物理结构。物理结构就是数据在真实内存上存储的结构,由于其底层实现是数组所以在物理结构上是连续的。而逻辑结构就是数据抽象出来的排序方式,我们会想象顺序表的下一个数据会与本数据按顺序排列起来,所以逻辑结构上也是连续的。根据其实现方式的不同,顺序表通常分为两种:
-
静态顺序表:使用固定大小的静态数组来存储数据。它的优点是实现简单,但缺点与普通数组一样,无法根据数据量灵活调整大小。
//静态顺序表
typedef int SLDataType;
#define N 7
typedef struct SeqList
{
SLDataType a[N]; //定长数组
int size; //有效数据个数
}SL;
-
动态顺序表:使用动态分配的数组来存储数据,可以根据需要自动扩容。这使其具有更高的灵活性,也是我们今天要重点探讨和实现的对象。
一个动态顺序表通常由以下三个关键部分构成:
-
SLDatatype* arr
: 一个指向存储数据的动态数组。 -
int size
: 记录当前顺序表中元素的个数。 -
int capacity
: 记录当前动态数组的总容量。
// 声明顺序表
typedef int SLDatatype;
typedef struct SeqList
{
SLDatatype* arr; // 指向动态数组的指针
int size; // 顺序表中当前元素的个数
int capacity; // 动态数组的总容量
}SL;
通过这辆“三驾马车”,我们就能轻松地管理这个“动态”数组了!!!
三.核心操作实现:增删查改的艺术
3.1 初始化与销毁
这是任何数据结构都必不可少的第一步。初始化时,我们将指针置为空,大小和容量都设为0;销毁时,释放动态数组占用的内存,并将指针置空,避免“野指针”问题。
// 顺序表初始化
void SLinit(SL* ps)
{
ps->arr = NULL;
ps->size = 0;
ps->capacity = 0;
}
// 顺序表销毁
void SLdelete(SL* ps)
{
free(ps->arr);
ps->arr = NULL;
ps->size = 0;
ps->capacity = 0;
}
// 打印顺序表内容
void SLPrint(SL* ps)
{
for (int i = 0;i < ps->size;i++)
{
printf("%d ", ps->arr[i]);
}
printf("\n"); // 打印完后换行,让输出更整洁
}
注意:在顺序表中,size 代表了当前元素的数量,它在数值上恰好等于下一个新元素可以插入的位置的下标。如下图所示:
3.2 扩容检查
这是顺序表能够“动态”的关键。每次进行插入操作前,我们都先调用这个函数。当 size 等于 capacity 时,说明空间不够了,需要利用 realloc 函数来重新分配更大的内存。如果当前容量为0,我们初始化一个较小的容量(比如4),之后每次都按两倍大小进行扩容,这是一种常见的策略,可以平衡空间利用和时间开销。
static void SLCheck(SL* ps)
{
if (ps->size == ps->capacity)
{
int newcapacity = ((ps->size == 0) ? 4 : (ps->capacity * 2)); // 动态扩容策略
SLDatatype* tmp = (SLDatatype*)realloc(ps->arr, (size_t)newcapacity * sizeof(SLDatatype));
if (tmp == NULL)
{
perror("realloc");
exit(1); // 内存分配失败,程序退出
}
ps->arr = tmp;
ps->capacity = newcapacity;
}
}
3.3 插入操作 (Push & Insert)
3.3.1 尾插(PushBack)
这是最简单的插入方式。先检查容量,如果不足就扩容;然后直接在 size 位置插入新元素,并增加 size。
void SLPushBack(SL* ps, SLDatatype x)
{
assert(ps);
SLCheck(ps); // 检查容量
ps->arr[ps->size++] = x; // 在末尾插入并增加size
}
3.3.2 头插 (PushFront)
这个操作相对复杂。在插入前,需要将所有现有元素向后移动一位,为新元素腾出空间。
void SLPushFront(SL* ps, SLDatatype x)
{
assert(ps);
SLCheck(ps); // 检查容量
// 将所有元素向后移动一位
for (int i = ps->size - 1;i >= 0 ;i--)
{
ps->arr[i + 1] = ps->arr[i];
}
ps->arr[0] = x; // 插入新元素
++ps->size;
}
3.3.3 任意位置插入 (Insert)
这是头插的通用版本。需要先将 pos 之后的所有元素向后移动一位。
void SLInsert(SL* ps, int pos, SLDatatype x)
{
assert(ps);
// 确保插入位置合法
assert((0 <= pos) && (pos <= ps->size));
SLCheck(ps); // 检查容量
// 将pos及之后的所有元素向后移动
for (int i = ps->size;i > pos;i--)
{
ps->arr[i] = ps->arr[i - 1];
}
ps->arr[pos] = x; // 在指定位置插入新元素
++ps->size;
}
3.4 删除操作 (Pop & Pop)
3.4.1 尾删 (PopBack)
最简单的删除。只需要将 size 减一即可,逻辑上删除了元素,但物理内存并不会立刻释放。
void SLPopBack(SL* ps)
{
assert(ps);
assert(ps->size); // 确保非空
--ps->size;
}
3.4.2 头删 (PopFront)
与头插相反,需要将所有元素向前移动一位,覆盖掉被删除的第一个元素。
void SLPopFront(SL* ps)
{
assert(ps);
assert(ps->size); // 确保非空
// 将所有元素向前移动一位
for (int i = 0;i < ps->size - 1;i++)
{
ps->arr[i] = ps->arr[i + 1];
}
--ps->size;
}
3.4.3 任意位置删除 (Pop)
头删的通用版本。将 pos 之后的元素向前移动,覆盖掉 pos 位置的元素。
void SLPop(SL* ps, int pos)
{
assert(ps);
// 确保删除位置合法
assert((0 <= pos) && (pos < ps->size));
// 将pos及之后的所有元素向前移动
for (int i = pos;i < ps->size - 1;i++)
{
ps->arr[i] = ps->arr[i + 1];
}
--ps->size;
}
3.5 查找(Find)
这是顺序表的一大优势。由于元素是连续存储的,我们可以通过遍历数组,利用下标快速访问到任何元素。
int SLFind(SL* ps, SLDatatype x)
{
assert(ps);
// 遍历数组查找
for (int i = 0;i < ps->size;i++)
{
if (x == ps->arr[i])
{
return i; // 找到则返回索引
}
}
return -1; // 找不到返回-1
}
修改操作由于其快速的访问特性,通常可以直接通过下标 ps->arr[index] = newValue 来完成,因此我们通常不需要单独封装一个修改函数。
四.结语
顺序表是所有数据结构的基础,它简单、直观,并且效率高。特别是在随机访问(通过下标访问)的场景下,其时间复杂度为O(1),比链表等其他数据结构快得多。当然,它也有缺点:插入和删除操作(特别是头部和中部)需要移动大量元素,时间复杂度为O(N),效率较低。
感谢大家阅读本文。如果您对上述代码或顺序表有任何疑问和见解,欢迎在评论区一起交流讨论!