核心摘要: 本文使用C语言从零实现动态顺序表,重点讲解内存分配策略、增删改查的核心算法以及边界条件处理,解决"数组越界"和"扩容无效"两大常见问题。
一、顺序表的基本概念与结构设计
1. 逻辑结构与物理结构对比
(1) 逻辑结构:线性表(元素之间具有一对一的前后关系)。
(2) 物理结构:连续的内存地址(与数组一致,但支持动态扩展)。
2. 静态数组 vs 动态顺序表
(1) 静态数组的致命缺陷:容量固定,编译时确定,易溢出。
(2) 动态顺序表的优势:运行时扩容,内存利用率高。
3. 结构体定义(C语言描述)
采用 SeqList 结构体封装三个必要属性:
c
typedef int SLDataType; // 方便后续修改数据类型(如改为char或double)
typedef struct SeqList
{
SLDataType* a; // 指向动态开辟数组的指针
int size; // 有效数据个数(当前长度)
int capacity; // 当前最大容量(总空间大小)
} SeqList;
a. 为什么需要 capacity? ------ 区分"已用空间"和"总空间",是扩容判断的依据。
b. 为什么用 typedef 定义数据类型? ------ 提高代码复用性,一行修改即可适配int/float/struct。
二、核心操作的实现(增删改查)
1. 初始化与销毁(内存管理)
(1) 初始化 :将指针置 NULL,size 和 capacity 置 0。
c
void SLInit(SeqList* psl);
(2) 销毁 :释放 psl->a,重新调用初始化(防止野指针)。
c
void SLDestroy(SeqList* psl);
2. 扩容机制(核心难点)
(1) 触发条件 :size == capacity。
(2) 扩容策略:
a. 小容量场景:capacity 为 0 时,开辟 4 个元素大小。
b. 大容量场景:新容量 = 原容量 × 2(指数增长,减少 realloc 次数)。
(3) 扩容函数实现:
c
void SLCheckCapacity(SeqList* psl)
{
if (psl->size == psl->capacity)
{
int newCapacity = (psl->capacity == 0 ? 4 : psl->capacity * 2);
SLDataType* tmp = (SLDataType*)realloc(psl->a, newCapacity * sizeof(SLDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
psl->a = tmp;
psl->capacity = newCapacity;
}
3. 增(头插 / 尾插 / 任意位置插入)
(1) 尾插 :先检查容量,再在 psl->a[psl->size] 处赋值,最后 size++。时间复杂度 O(1)。
(2) 头插 :从后往前移动元素(for 循环从 size 到 1),腾出 [0] 位置。时间复杂度 O(n)。
(3) 任意位置插入 :将 [pos, size-1] 全部后移一位,再插入。必须判断 pos 合法性(0 ≤ pos ≤ size)。
4. 删(头删 / 尾删 / 任意位置删除)
(1) 尾删 :直接 size--(注意:不需要真正清除内存,下次插入会覆盖)。
(2) 头删 :从前往后移动元素(for 循环从 1 到 size-1),最后 size--。
(3) 任意位置删除 :将 [pos+1, size-1] 前移覆盖 pos 位置。
⚠️ 删除操作的三重检查:
⓵ 断言 psl 不为空。
⓶ 断言 psl->size > 0(空表不能删)。
⓷ 检查 pos 范围:0 ≤ pos < size。
5. 查与改
(1) 按值查找:遍历数组,返回第一个匹配的下标(找不到返回 -1)。
(2) 按位置修改 :直接赋值 psl->a[pos] = x,前提是 pos < size。
(3) 按值修改:先查找位置,再修改(查找逻辑复用)。
三、打印与调试辅助函数
1. 打印顺序表
遍历 size 次,输出 psl->a[i],格式统一为 [1, 2, 3, 4]。
2. 菜单交互示例(用于控制台测试)
提供 0-7 的数字菜单:
⓵ 尾插 ⓶ 头插 ⓷ 尾删 ⓸ 头删
⓹ 任意插入 ⓺ 任意删除 ⓻ 查找 ⓼ 打印 ⓿ 退出
四、常见错误与避坑指南(实用性重点)
1. 扩容时的野指针问题
(1) 错误写法 :直接用 realloc(psl->a, newSize) 且不判断返回值。
(2) 正确写法 :用临时指针 tmp 接收 realloc 返回值,成功后再赋值给 psl->a。
原因 :realloc 失败返回 NULL,直接赋值会丢失原有内存地址,导致内存泄漏且无法恢复。
2. 删除/插入时的越界访问
(1) 头插移动元素时,必须从后往前移动 (for (i = size; i > pos; i--))。
(2) 头删移动元素时,必须从前往后移动 (for (i = pos; i < size-1; i++))。
口诀:插入向后挪,删除向前盖;反向操作必越界。
3. 结构体传值 vs 传址
(1) 错误 :函数参数写 void func(SeqList sl),修改的是临时副本。
(2) 正确 :一律传指针 void func(SeqList* psl),用 -> 访问成员。
4. 缩容的误区
不建议在删除元素时立即缩容(频繁缩容会导致性能抖动)。业界通用做法:只扩不缩 ,或仅在剩余空间占比极低时(如 size < capacity/4)考虑缩容。
五、完整代码文件结构(项目组织)
1. 分文件编写(工业级规范)
(1) SeqList.h ------ 头文件:结构体定义、函数声明、宏定义。
(2) SeqList.c ------ 源文件:所有函数的具体实现。
(3) test.c ------ 测试文件:main 函数和菜单交互。
2. 头文件中的防重复包含
c
#ifndef _SEQLIST_H_
#define _SEQLIST_H_
// 所有声明放在这里
#endif
3. 编译命令示例(gcc)
bash
gcc -o test SeqList.c test.c -std=c99 -Wall
六、顺序表的性能总结与适用场景
1. 时间复杂度汇总
| 操作 | 时间复杂度 | 备注 |
|---|---|---|
| 尾插 / 尾删 | O(1) | 平均情况(考虑扩容均摊后仍为 O(1)) |
| 头插 / 头删 / 任意位置插入删除 | O(n) | 需要移动大量元素 |
| 按位置访问 | O(1) | 随机访问是顺序表的最大优势 |
| 按值查找 | O(n) | 无序情况下必须遍历 |
2. 适用场景(什么时候用顺序表)
⓵ 需要频繁随机访问(如通过下标取第 i 个元素)。
⓶ 插入/删除操作只在尾部进行(栈结构)。
⓷ 元素个数可预估,或对内存连续有硬性要求(如 DMA 传输)。
3. 不适用场景(什么时候该换链表)
⓵ 频繁在头部或中间插入/删除。
⓶ 元素个数极其不稳定,且单个元素体积很大(扩容拷贝成本高)。