数据结构
1.2 顺序表
顺序表的存储、插入、删除与查找,附 C 语言实现。
笔记更新 收录于
§ 1.2.1 为什么使用连续存储#
- 若操作经常给出逻辑位序,例如“读取第 100 个元素”,最直接的办法是让每个元素占用相同大小,并把它们依次放入连续地址。
- 这样不必从第一个元素开始寻找,只需用首地址加上固定偏移。
连续布局同时带来约束:
- 数组中第 个位置之前必须恰好保存前 个元素,不能出现空洞。
- 中间插入不能直接占用已有位置,需要先移动后缀。
- 中间删除会留下空洞,需要移动后缀将其填上。
- 当前长度和已分配容量必须分开记录。
因此,顺序表以连续空间换取直接定位和良好局部性,也承担后缀移动与容量管理的代价。
§ 1.2.2 存储表示与不变量#
一、静态顺序表#
静态顺序表把最大容量编入类型,适合容量上界明确的场景。以下代码统一采用 C11,逻辑位序从 1 开始,数组下标从 0 开始。
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define SEQ_CAPACITY 50
typedef int ElemType;
typedef struct {
ElemType data[SEQ_CAPACITY];
size_t length;
} SqList;
void InitSqList(SqList *list) {
if (list != NULL) {
list->length = 0;
}
}c- 初始化只需把
length置为 0。- 空表中数组槽位的旧位模式不属于有效元素,不需要逐个清零。
静态顺序表必须始终满足:
0 <= length && length <= SEQ_CAPACITY。- 有效元素恰好位于
data[0]到data[length - 1]。 data[length]及其后的槽位不属于当前线性表,即使其中仍残留旧值。- 第 个逻辑元素对应
data[i - 1]。
二、动态顺序表#
动态顺序表仍然使用连续空间,只是数组在运行时申请,并在容量不足时整体迁移。
#include <stdint.h>
#include <stdlib.h>
typedef struct {
ElemType *data;
size_t length;
size_t capacity;
} DynamicList;
bool InitDynamicList(DynamicList *list, size_t initial_capacity) {
if (list == NULL) return false;
list->data = NULL;
list->length = 0;
list->capacity = 0;
if (initial_capacity == 0) return true;
if (initial_capacity > SIZE_MAX / sizeof *list->data) {
return false;
}
list->data = malloc(initial_capacity * sizeof *list->data);
if (list->data == NULL) return false;
list->capacity = initial_capacity;
return true;
}
void DestroyDynamicList(DynamicList *list) {
if (list == NULL) return;
free(list->data);
list->data = NULL;
list->length = 0;
list->capacity = 0;
}c动态分配不会把顺序表变成链表:扩容前后,每一时刻的有效元素仍位于一段连续地址中。它的不变量还包括:
length <= capacity。- 当
capacity == 0时,data可以为NULL,此时length必须为 0。 - 当
capacity > 0时,data指向至少能容纳capacity个ElemType的连续区域。
§ 1.2.3 地址计算与两套编号#
设首元素地址为 ,每个元素占 个字节,则第 个元素的地址为:
公式解读
- :第 个逻辑元素的起始地址。
- :连续存储区的起始地址。
- :第 个元素之前已有的元素数。
- :每个元素占用的字节数,实际 C 实现中对应
sizeof(ElemType)。 - 整句可读为:目标地址等于首地址加上前面 个等长元素的总字节数。
| 逻辑含义 | 位序 | 数组下标 |
|---|---|---|
| 第一个元素 | 1 | 0 |
| 第 个元素 | ||
| 最后一个有效元素 | length | length - 1 |
| 表尾追加位置 | length + 1 | length |
§ 1.2.4 查询操作#
一、按位读取#
bool GetElem(const SqList *list, size_t position, ElemType *out_value) {
if (list == NULL || out_value == NULL) return false;
if (position < 1 || position > list->length) return false;
*out_value = list->data[position - 1];
return true;
}c合法性检查和一次下标访问都不随表长增长,因此按位读取为 。
二、按值查找#
bool LocateElem(
const SqList *list,
ElemType value,
size_t *out_position
) {
if (list == NULL || out_position == NULL) return false;
for (size_t index = 0; index < list->length; ++index) {
if (list->data[index] == value) {
*out_position = index + 1;
return true;
}
}
return false;
}c这段实现返回第一个匹配元素的位序:
- 最好情况在首元素命中,只比较 1 次,为 。
- 最坏情况在表尾命中或不存在,需要比较 次,为 。
- 若表按关键字有序,可以另用折半查找达到 ;这是有序性和算法带来的收益,不是普通
LocateElem自动拥有的性质。
§ 1.2.5 插入操作#
在长度为 的表中,第 position 位插入的合法范围是 1 到 。
一、状态变化#
- 检查对象、位序和剩余容量。
- 从最后一个元素开始,把原第
position位到第 位依次后移。 - 把新值写入下标
position - 1。 - 最后把
length增加 1。
bool ListInsert(SqList *list, size_t position, ElemType value) {
if (list == NULL) return false;
if (position < 1 || position > list->length + 1) return false;
if (list->length == SEQ_CAPACITY) return false;
for (size_t j = list->length; j >= position; --j) {
list->data[j] = list->data[j - 1];
}
list->data[position - 1] = value;
++list->length;
return true;
}c二、移动次数#
第 位插入需要移动 个旧元素:
- 表尾追加 :移动 0 个,时间为 。
- 表头插入 :移动 个,时间为 。
- 若 个插入位序等概率,平均移动次数为:
公式解读
- :本次插入的逻辑位序。
- :插入点到原表尾之间需要后移的元素数。
- 分母 :长度为 的表共有 个合法插入位序。
- 整句可读为:在等概率位置假设下,插入平均移动一半旧元素,因此平均时间为 。
三、动态扩容#
动态顺序表在 length == capacity 时可以先扩容。下面采用“容量翻倍,零容量先变为 1”的策略:
bool ReserveForInsert(DynamicList *list) {
if (list == NULL) return false;
if (list->length < list->capacity) return true;
size_t new_capacity = list->capacity == 0
? 1
: list->capacity * 2;
if (new_capacity < list->capacity) return false;
if (new_capacity > SIZE_MAX / sizeof *list->data) return false;
ElemType *new_data =
realloc(list->data, new_capacity * sizeof *new_data);
if (new_data == NULL) return false;
list->data = new_data;
list->capacity = new_capacity;
return true;
}c- 必须先用临时指针接收
realloc结果;若直接覆盖list->data,分配失败会丢失原内存地址。- 翻倍策略下,表尾追加的单次最坏时间仍可能是 ,但连续多次追加的均摊时间为 ,推导见 均摊分析。
§ 1.2.6 删除操作#
一、状态变化#
- 检查删除位序是否在 1 到 。
- 保存被删值。
- 从被删元素的后继开始,由前向后依次覆盖前一个位置。
- 把
length减少 1。
bool ListDelete(
SqList *list,
size_t position,
ElemType *out_value
) {
if (list == NULL || out_value == NULL) return false;
if (position < 1 || position > list->length) return false;
*out_value = list->data[position - 1];
for (size_t j = position; j < list->length; ++j) {
list->data[j - 1] = list->data[j];
}
--list->length;
return true;
}c- 第 位删除需要移动 个元素:删表尾移动 0 个,删表头移动 个。
- 若 个删除位序等概率,平均移动次数为 。
§ 1.2.7 完整可复算示例#
§ 1.2.8 复杂度、选择与失败路径#
| 操作 | 最好时间 | 最坏时间 | 代价来源 |
|---|---|---|---|
| 按位读取 | 地址可直接计算 | ||
| 普通按值查找 | 比较直到命中或扫描结束 | ||
| 插入 | 移动插入点后的旧元素;动态表还可能扩容 | ||
| 删除 | 移动删除点后的旧元素 | ||
| 顺序遍历 | 每个有效元素访问一次 |
理解检查
- 为什么第 个元素位于
data[i - 1]? - 在长度为 7 的表中第 3 位插入,需要移动多少个旧元素,移动方向是什么?
- 动态顺序表扩容后,为什么旧的元素指针可能失效?
参考回答:
- 位序从 1 开始,而数组下标从 0 开始;第 个元素前有 个元素。
- 移动 个元素,从表尾向插入位置移动,避免覆盖尚未读取的旧值。
- 扩容可能申请新地址并复制元素,再释放旧区域;指向旧区域的指针因此不再指向当前数组。
总结#
- 顺序表用连续空间保存线性表,第 个元素映射到
data[i - 1],因此按位访问为 ,普通按值查找仍可能为 。 - 中间插入必须从后向前移动后缀,删除必须从前向后填补空洞;二者平均和最坏时间均为 。
- 动态扩容仍保持连续存储;实现必须检查容量计算和分配失败,并注意扩容可能使旧元素指针失效。几何扩容下,连续表尾追加的均摊时间为 。