🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:把动态顺序表从"会写接口"推进到"会做项目",完整串起联系人建模、增删查改、文件持久化、菜单交互,以及两道顺序表经典算法题。
💡 学习目标:理解容器层与业务层如何解耦;能独立实现一个可持久化通讯录;掌握"移除元素"和"合并两个有序数组"的双指针思路;能从复杂度和内存模型解释顺序表的优缺点。
📌 环境约定 :代码使用标准 C99 写法,示例数据文件采用二进制格式。
scanf_s、控制台中文编码等平台差异不在本文展开;正式项目应进一步处理输入长度、文件格式版本和写入失败恢复。
文章目录
- 一、从顺序表接口走向真实项目
- 二、先把项目分层:容器、业务、交互与文件
- 三、设计联系人与动态顺序表
- 四、初始化与加载:让历史数据回到内存
- 五、添加与展示:最直接的两项业务
- 六、查找、删除与修改:先把姓名变成下标
- 七、保存、销毁与菜单循环
- 八、课堂代码之外必须补上的工程细节
- 九、经典算法一:移除元素
- 十、经典算法二:合并两个有序数组
- 十一、顺序表的问题,以及为什么还需要链表
- 十二、高频问答、验收清单与练习
- 总结
一、从顺序表接口走向真实项目
上一讲实现动态顺序表时,我们关心的是这些基本操作:
- 初始化与销毁;
- 尾插、头插与指定位置插入;
- 尾删、头删与指定位置删除;
- 查找、访问、修改;
- 容量不足时扩容。
这些接口解决了"怎样管理一段连续的动态空间",但它们还没有回答一个更重要的问题:顺序表究竟能拿来做什么?
通讯录非常适合作为第一个应用项目。一个联系人是一条结构化记录,多名联系人天然组成一个线性序列;添加、删除、查找、修改和展示,又恰好可以映射到顺序表的常见操作。
项目需求可以概括为:
- 至少能够存储 100 名联系人的信息;
- 每条记录包含姓名、性别、年龄、电话和地址;
- 支持添加、删除、查找、修改和展示;
- 程序退出后数据不能丢失,下次启动还要能够恢复。
第四点把项目从"内存练习"推进到了"完整应用"。内存随着进程结束而失效,想让历史信息保留下来,就必须把当前状态写入文件,并在下一次启动时读回来。
本项目真正串起了四块基础知识:结构体负责描述一条记录,动态内存负责容纳可变数量的记录,顺序表负责组织记录,文件操作负责跨运行周期保存记录。
二、先把项目分层:容器、业务、交互与文件
如果把所有代码都塞进 main,项目很快会变成大量 scanf、循环、realloc 和数组移动混在一起的"大函数"。更清晰的做法是把职责拆成四层。

| 层次 | 主要文件 | 职责 |
|---|---|---|
| 交互层 | test.c |
打印菜单、读取命令、调用业务接口 |
| 业务层 | contact.c/.h |
添加、删除、查找、修改、展示、加载和保存联系人 |
| 容器层 | SeqList.c/.h |
管理连续动态空间、扩容、移动元素和维护 size/capacity |
| 持久化层 | 数据文件 | 在进程结束后保存联系人记录 |
这里最关键的复用技巧,是把顺序表的元素类型改成联系人类型:
c
typedef struct PersonInfo SeqDataType;
于是,顺序表不再存 int,而是存一个个 PersonInfo。容器层仍然只关心"元素怎样插入和移动",并不需要理解姓名、电话或地址的业务含义。
业务层又可以给顺序表起一个更贴合语义的别名:
c
typedef struct SeqList Contact;
这句话并没有创建一种新的内存布局。Contact 仍然是 struct SeqList,只是当它出现在通讯录代码中时,阅读者更容易理解变量的角色。
text
菜单命令
↓
Contact 业务操作
↓
SeqList 容器接口
↓
连续的 PersonInfo 数组
↕
contact.dat
分层的价值:联系人字段发生变化时,不需要重新实现扩容;顺序表的扩容策略改变时,也不需要重写菜单和查找逻辑。
三、设计联系人与动态顺序表
3.1 一条联系人记录
先定义业务数据。数组长度必须包含字符串结尾的 \0,所以 11 位手机号至少需要 12 个字节。为了给国家码、分隔符或将来的格式变化留余量,这里直接使用 16。
c
// contact.h
#pragma once
#include <stddef.h>
#define NAME_MAX_LEN 50
#define SEX_MAX_LEN 8
#define TEL_MAX_LEN 16
#define ADDR_MAX_LEN 100
typedef struct PersonInfo
{
char name[NAME_MAX_LEN];
char sex[SEX_MAX_LEN];
int age;
char tel[TEL_MAX_LEN];
char addr[ADDR_MAX_LEN];
} PeoInfo;
为什么不把电话号码定义成整数?因为电话号码不是参与算术的数值:
- 可能以
0开头; - 可能包含
+86、空格或短横线; - 位数可能超过某些整数类型范围;
- 我们只需要存储、比较和显示它。
因此,电话号码应当按字符串保存。
3.2 顺序表的三个核心成员
c
// SeqList.h
#pragma once
#include <stddef.h>
#include "contact.h"
typedef PeoInfo SeqDataType;
typedef struct SeqList
{
SeqDataType* data;
size_t size;
size_t capacity;
} SeqList;
void SeqListInit(SeqList* ps);
void SeqListDestroy(SeqList* ps);
void SeqListPushBack(SeqList* ps, SeqDataType value);
void SeqListErase(SeqList* ps, size_t pos);

三个成员必须始终满足对象不变式:
text
0 <= size <= capacity
capacity == 0 时,data 可以为 NULL
capacity > 0 时,data 指向至少 capacity 个元素的可用空间
有效联系人只位于 data[0] ~ data[size - 1]
所有公开接口都应维护这些条件。只要某次失败操作破坏了 size、capacity 或 data 之间的关系,后面的任意操作都可能越界。
3.3 初始化、扩容与尾插
c
#include "SeqList.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
void SeqListInit(SeqList* ps)
{
assert(ps != NULL);
ps->data = NULL;
ps->size = 0;
ps->capacity = 0;
}
static int SeqListCheckCapacity(SeqList* ps)
{
if (ps->size < ps->capacity)
return 1;
size_t new_capacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
SeqDataType* tmp = realloc(ps->data,
new_capacity * sizeof(SeqDataType));
if (tmp == NULL)
{
perror("realloc");
return 0;
}
ps->data = tmp;
ps->capacity = new_capacity;
return 1;
}
void SeqListPushBack(SeqList* ps, SeqDataType value)
{
assert(ps != NULL);
if (!SeqListCheckCapacity(ps))
return;
ps->data[ps->size] = value;
++ps->size;
}
这里用临时指针接收 realloc。如果直接写:
c
ps->data = realloc(ps->data, new_capacity * sizeof(SeqDataType));
一旦扩容失败,realloc 返回 NULL,原地址就会被覆盖并丢失,形成内存泄漏。临时指针让失败路径能够保留原顺序表。
若需求明确要求"至少存储 100 人",也可以初始化时直接预留 100 个元素;但动态顺序表更常见的策略是从较小容量开始,在需要时按倍数增长。
四、初始化与加载:让历史数据回到内存
通讯录启动时不能只调用 SeqListInit。初始化得到的是空容器,随后还要把文件中的历史记录逐条读入。

先在头文件中声明业务接口:
c
typedef struct SeqList Contact;
void ContactInit(Contact* con);
void ContactAdd(Contact* con);
void ContactDelete(Contact* con);
void ContactFind(const Contact* con);
void ContactModify(Contact* con);
void ContactShow(const Contact* con);
void ContactDestroy(Contact* con);
4.1 加载函数
c
// contact.c
#include "contact.h"
#include "SeqList.h"
#include <stdio.h>
#include <string.h>
#define CONTACT_FILE "contact.dat"
static void ContactLoad(Contact* con)
{
FILE* pf = fopen(CONTACT_FILE, "rb");
if (pf == NULL)
{
// 首次运行时文件可能尚不存在,这不是致命错误。
return;
}
PeoInfo info;
while (fread(&info, sizeof(info), 1, pf) == 1)
{
SeqListPushBack(con, info);
}
if (ferror(pf))
perror("fread");
fclose(pf);
}
void ContactInit(Contact* con)
{
SeqListInit(con);
ContactLoad(con);
}
循环条件直接检查 fread 是否成功读到一条完整记录,优于先判断 feof:
c
while (!feof(pf)) // 不推荐
EOF 标志只有在一次读取尝试失败后才会设置。用 feof 控制循环,容易多处理一次旧数据或写出绕弯的错误逻辑。
4.2 为什么加载时继续调用 SeqListPushBack
加载函数不应该自己修改 size、判断容量并调用 realloc。这些已经是容器层的职责。复用 SeqListPushBack 可以让所有插入统一遵守相同的不变式和扩容策略。
当然,这也带来一个性能细节:如果文件中有很多记录,逐条尾插可能触发多次扩容。更完整的文件格式可以在头部保存记录数,加载时一次性预留足够容量。不过对于教学项目,逐条插入更直观,也足够安全。
五、添加与展示:最直接的两项业务
5.1 添加联系人
为了避免无界 %s 写爆字符数组,可以给格式串指定最大宽度。宽度不包含结尾的 \0,因此要比数组容量小 1。
c
void ContactAdd(Contact* con)
{
PeoInfo info = {0};
printf("姓名:");
if (scanf("%49s", info.name) != 1)
return;
printf("性别:");
if (scanf("%7s", info.sex) != 1)
return;
printf("年龄:");
if (scanf("%d", &info.age) != 1)
return;
printf("电话:");
if (scanf("%15s", info.tel) != 1)
return;
printf("地址:");
if (scanf("%99s", info.addr) != 1)
return;
SeqListPushBack(con, info);
printf("添加成功。\n");
}
数组名 info.name 在这里会转换为首元素地址,因此不应写成 &info.name。二者数值地址通常相同,但类型不同:
c
info.name // char*
&info.name // char (*)[NAME_MAX_LEN]
可变参数函数无法替我们修正这种类型不匹配。启用编译器警告,能尽早发现问题。
示例中的
%s仍不能读取包含空格的地址。若要支持"北京市 海淀区"这类输入,应改用fgets,并统一处理上一轮输入留下的换行符。
5.2 展示联系人
c
void ContactShow(const Contact* con)
{
printf("%-16s %-8s %-6s %-16s %-30s\n",
"姓名", "性别", "年龄", "电话", "地址");
for (size_t i = 0; i < con->size; ++i)
{
const PeoInfo* p = &con->data[i];
printf("%-16s %-8s %-6d %-16s %-30s\n",
p->name, p->sex, p->age, p->tel, p->addr);
}
}
这里使用 const Contact*,明确函数只读通讯录。const 不只是"防手滑",也是接口契约:调用者看到声明,就知道展示操作不会改变容器状态。
终端中中文字符的显示宽度并不总等于字节数,所以 printf 的字段宽度只能提供粗略对齐。若目标是稳定的跨平台表格,需要使用理解 Unicode 显示宽度的终端库或图形界面。
六、查找、删除与修改:先把姓名变成下标
顺序表擅长按下标访问,但用户输入的是姓名。因此业务层需要一个桥梁:扫描联系人数组,把姓名转换成位置。

6.1 按姓名查找位置
c
static int ContactFindByName(const Contact* con, const char* name)
{
for (size_t i = 0; i < con->size; ++i)
{
if (strcmp(con->data[i].name, name) == 0)
return (int)i;
}
return -1;
}
这里用 -1 表示未找到,因此返回类型选择 int。如果项目可能存储超过 INT_MAX 条记录,应改用 size_t 并以 SIZE_MAX 作为失败哨兵,或通过输出参数返回位置。
按姓名线性扫描的时间复杂度为 O(N)。数组支持 O(1) 随机访问,指的是"已知下标时访问元素";它并不会让"按内容查找"自动变成 O(1)。
6.2 删除联系人
c
void SeqListErase(SeqList* ps, size_t pos)
{
assert(ps != NULL);
assert(pos < ps->size);
for (size_t i = pos; i + 1 < ps->size; ++i)
ps->data[i] = ps->data[i + 1];
--ps->size;
}
void ContactDelete(Contact* con)
{
char name[NAME_MAX_LEN];
printf("要删除的姓名:");
if (scanf("%49s", name) != 1)
return;
int pos = ContactFindByName(con, name);
if (pos < 0)
{
printf("联系人不存在。\n");
return;
}
SeqListErase(con, (size_t)pos);
printf("删除成功。\n");
}
删除包含两个阶段:先查找,再搬移后续元素。
- 查找最坏扫描
N个元素; - 删除位置靠前时,最坏搬移
N-1个元素; - 两项相加仍是
O(N),不是O(N²)。
6.3 查找并展示
c
static void PrintOne(const PeoInfo* p)
{
printf("姓名:%s\n", p->name);
printf("性别:%s\n", p->sex);
printf("年龄:%d\n", p->age);
printf("电话:%s\n", p->tel);
printf("地址:%s\n", p->addr);
}
void ContactFind(const Contact* con)
{
char name[NAME_MAX_LEN];
printf("要查找的姓名:");
if (scanf("%49s", name) != 1)
return;
int pos = ContactFindByName(con, name);
if (pos < 0)
{
printf("联系人不存在。\n");
return;
}
PrintOne(&con->data[pos]);
}
6.4 修改联系人
最简单的修改策略,是找到记录后重新输入全部字段:
c
void ContactModify(Contact* con)
{
char old_name[NAME_MAX_LEN];
printf("要修改的姓名:");
if (scanf("%49s", old_name) != 1)
return;
int pos = ContactFindByName(con, old_name);
if (pos < 0)
{
printf("联系人不存在。\n");
return;
}
PeoInfo updated = {0};
printf("新姓名:");
if (scanf("%49s", updated.name) != 1) return;
printf("新性别:");
if (scanf("%7s", updated.sex) != 1) return;
printf("新年龄:");
if (scanf("%d", &updated.age) != 1) return;
printf("新电话:");
if (scanf("%15s", updated.tel) != 1) return;
printf("新地址:");
if (scanf("%99s", updated.addr) != 1) return;
con->data[pos] = updated;
printf("修改成功。\n");
}
先读入临时对象,再一次性覆盖原记录,有一个重要好处:如果输入在中途失败,旧联系人不会处于"只改了一半"的状态。这是一种很实用的先构造完整新状态,再提交的思路。
七、保存、销毁与菜单循环
7.1 退出前保存

c
static int ContactSave(const Contact* con)
{
FILE* pf = fopen(CONTACT_FILE, "wb");
if (pf == NULL)
{
perror("fopen");
return 0;
}
size_t written = fwrite(con->data,
sizeof(PeoInfo),
con->size,
pf);
int close_ok = fclose(pf) == 0;
if (written != con->size || !close_ok)
{
fprintf(stderr, "通讯录保存不完整。\n");
return 0;
}
return 1;
}
一次性写入 con->size 条记录,比循环调用 fwrite 更简洁。con->data 在 size == 0 时可以为 NULL,只要写入元素个数也是 0,就不会解引用它。
7.2 销毁顺序表
c
void SeqListDestroy(SeqList* ps)
{
assert(ps != NULL);
free(ps->data);
ps->data = NULL;
ps->size = 0;
ps->capacity = 0;
}
void ContactDestroy(Contact* con)
{
if (ContactSave(con))
printf("通讯录已保存。\n");
SeqListDestroy(con);
}
释放以后把指针和两个计数都归零,可以让对象回到与"空顺序表"一致的可识别状态,降低误用悬空指针的风险。
7.3 菜单循环
c
enum MenuOption
{
EXIT,
ADD,
DELETE,
FIND,
MODIFY,
SHOW
};
static void Menu(void)
{
puts("********************************");
puts("***** 1. 添加 2. 删除 *****");
puts("***** 3. 查找 4. 修改 *****");
puts("***** 5. 展示 0. 退出 *****");
puts("********************************");
}
int main(void)
{
Contact con;
ContactInit(&con);
int option = -1;
do
{
Menu();
printf("请选择:");
if (scanf("%d", &option) != 1)
{
fprintf(stderr, "输入无效,程序结束。\n");
option = EXIT;
}
switch (option)
{
case ADD: ContactAdd(&con); break;
case DELETE: ContactDelete(&con); break;
case FIND: ContactFind(&con); break;
case MODIFY: ContactModify(&con); break;
case SHOW: ContactShow(&con); break;
case EXIT: break;
default: puts("没有这个选项,请重新输入。");
}
} while (option != EXIT);
ContactDestroy(&con);
return 0;
}
课程代码中常见的一个小问题,是把 0 也落入 default,导致用户选择退出时先打印"输入有误"。显式添加 case EXIT 可以让控制流与菜单含义一致。
八、课堂代码之外必须补上的工程细节
教学代码通常突出核心知识,不会穷尽所有失败场景。把它扩展成真正可长期使用的小工具时,至少还要考虑下面这些问题。
8.1 字符数组必须给 \0 留位置
c
char tel[11];
只能安全保存最多 10 个普通字符加终止符,不能保存完整的 11 位手机号。类似地,UTF-8 中文字符通常占多个字节,char sex[4] 未必足以保存"女性"等文本。
8.2 %s 不能读取带空格文本
姓名和电话通常没有空格,但地址很可能有。若要完整读取一行,可以使用:
c
fgets(info.addr, sizeof(info.addr), stdin);
info.addr[strcspn(info.addr, "\n")] = '\0';
混用 scanf 与 fgets 时,需要先处理输入缓冲区中残留的换行。更稳妥的做法是所有输入都先 fgets 到缓冲区,再用 strtol 等函数解析数字。
8.3 重名联系人需要明确策略
当前 ContactFindByName 返回第一个匹配项。如果存在两名同名联系人,删除或修改哪一个就不够明确。可选策略包括:
- 禁止姓名重复;
- 以电话号码作为唯一标识;
- 为每条记录生成不可重复的 ID;
- 查找后展示所有匹配项,再让用户二次选择。
8.4 直接写结构体不保证长期兼容
二进制写入 PeoInfo 很方便,但文件会受到以下因素影响:
- 编译器的结构体填充与对齐;
- 整数大小和字节序;
- 字段长度变化;
- 新版本增加或删除字段;
- 不同平台之间的编码差异。
因此,这种格式适合同一程序版本下的教学演示,却不适合作为跨平台、跨版本的长期交换格式。更稳健的设计可以选择 CSV、JSON、自定义带版本号的记录格式,或明确序列化每个字段。
8.5 保存失败时不能假装成功
fwrite 返回值和 fclose 都可能失败。正式程序常把数据写到临时文件,确认完全写入并刷新后,再用原子重命名替换旧文件。这样即使写入中途断电,也更不容易同时失去新旧两份数据。
8.6 业务接口也应该反馈失败
若 SeqListPushBack 扩容失败,它不应该只打印错误而让业务层继续宣布"添加成功"。更清晰的接口是返回状态:
c
int SeqListPushBack(SeqList* ps, SeqDataType value);
调用者根据返回值决定是否打印成功、重试或退出。打印日志和返回错误是两件事:底层库应优先把结果交给上层决定。
九、经典算法一:移除元素
题目给定数组 nums 和一个值 val,要求原地移除所有等于 val 的元素,并返回新长度。数组前 k 个元素必须是保留下来的结果,尾部内容无需关心。
例如:
text
输入:nums = [3, 2, 2, 3, 4, 2, 5], val = 2
输出:k = 4
有效前缀:[3, 3, 4, 5]

9.1 直接逐个删除为什么不理想
如果每遇到一个 val,就调用顺序表删除接口并搬移后面的全部元素,那么最坏情况下会发生:
text
第 1 次删除移动 N-1 个元素
第 2 次删除移动 N-2 个元素
...
总成本可能达到 O(N²)。
9.2 快慢指针原地覆盖
更好的办法是让 read 扫描所有输入,让 write 指向下一个应该写入保留元素的位置。
c
int removeElement(int* nums, int nums_size, int val)
{
int write = 0;
for (int read = 0; read < nums_size; ++read)
{
if (nums[read] != val)
{
nums[write] = nums[read];
++write;
}
}
return write;
}
循环不变式可以这样描述:
每轮开始时,
nums[0..write-1]恰好保存了已经扫描部分中所有不等于val的元素,并且相对顺序不变。
当 read 扫描完数组,write 就是保留元素的数量,也就是新长度。
9.3 复杂度
- 时间复杂度:
O(N),每个元素只读取一次; - 额外空间复杂度:
O(1); - 稳定性:保留元素的相对顺序不变。
这个思路本质上是原地稳定压缩。删除指定值、过滤无效记录、去掉不满足条件的元素,都可以套用相同框架。
十、经典算法二:合并两个有序数组
题目给出两个非递减数组:
nums1的前m个位置是有效数据,后面预留n个位置;nums2有n个有效元素;- 要把两者合并到
nums1,并保持有序。
text
nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6], n = 3
结果 = [1, 2, 2, 3, 5, 6]

10.1 为什么从前向后写会覆盖数据
如果从 nums1[0] 开始放置较小元素,当 nums2[j] 应该插到前面时,会覆盖 nums1 中还没有参与比较的有效值。为了避免额外开一个数组,可以反过来处理:每次选择较大值,放到 nums1 尾部空位。
10.2 三指针写法
c
void merge(int* nums1, int m, const int* nums2, int n)
{
int i = m - 1;
int j = n - 1;
int write = m + n - 1;
while (i >= 0 && j >= 0)
{
if (nums1[i] > nums2[j])
nums1[write--] = nums1[i--];
else
nums1[write--] = nums2[j--];
}
while (j >= 0)
nums1[write--] = nums2[j--];
}
最后只需要处理 nums2 的剩余元素:
- 如果
nums2还有值,它们必须复制到nums1前部; - 如果
nums1还有值,它们本来就在正确位置,不需要移动。
10.3 边界情况
应至少验证:
text
m = 0 nums1 原有效区间为空
n = 0 nums2 为空
nums1 全部小于 nums2 需要连续写入 nums2
nums1 全部大于 nums2 nums1 元素整体后移
存在大量重复值 结果仍保持非递减
10.4 复杂度
- 时间复杂度:
O(m+n); - 额外空间复杂度:
O(1); - 前提:
nums1的实际容量至少为m+n。
这道题的重要启发是:连续数组中如果目标区域的尾部有空闲空间,逆向写入常常能避免覆盖尚未处理的数据。
十一、顺序表的问题,以及为什么还需要链表
通讯录项目说明顺序表非常实用,但课程最后提出的三个问题也不能忽略。

11.1 中间或头部插入删除是 O(N)
顺序表要求有效元素连续排列。若在位置 pos 插入,就要把 [pos, size) 整体向后移动;若删除,就要把 (pos, size) 整体向前移动。
尾插在容量充足时是 O(1),但头插和中间插入通常是 O(N)。这不是代码写得不够巧,而是连续布局带来的固有成本。
11.2 扩容的一次成本是 O(N)
容量不足时通常要:
- 申请更大的连续空间;
- 复制或搬移原数据;
- 释放旧空间;
- 更新指针与容量。
一次扩容可能很贵,但按倍数扩容时,连续多次尾插的均摊复杂度 仍可做到 O(1)。原因是容量越大,扩容发生得越不频繁;把偶尔发生的大成本分摊到多次成功插入上,每次平均成本保持常数级。
11.3 预留容量可能浪费空间
假设容量从 100 扩到 200,随后只再插入 5 个元素并长期不变,那么剩余 95 个槽位就处于闲置状态。
可以通过以下策略缓解:
- 选择合适增长因子,例如 1.5 倍而不是固定 2 倍;
- 已知数据规模时提前
reserve; - 在明确不再增长时执行收缩;
- 对元素很大或插入删除频繁的场景,改用其他数据结构。
不过,频繁收缩同样会带来复制成本和容量抖动。空间与时间之间没有免费的选择。
11.4 链表解决了什么,又付出了什么
链表把元素放在彼此独立的结点中,通过指针连接。已知目标结点时,局部插入删除不需要整体搬移,也不要求申请一整块更大的连续空间。
代价则是:
- 无法按下标
O(1)随机访问; - 每个结点额外保存指针;
- 结点分散,缓存局部性通常弱于顺序表;
- 动态分配次数更多,代码和内存管理更复杂。
所以问题不是"顺序表和链表谁更高级",而是:
| 场景 | 更自然的选择 |
|---|---|
| 需要频繁按下标访问 | 顺序表 |
| 数据规模稳定、读取多 | 顺序表 |
| 主要在尾部追加 | 动态顺序表 |
| 已知结点位置后频繁局部插删 | 链表 |
| 无法获得大块连续空间 | 链式结构可能更合适 |
| 强调缓存友好与遍历速度 | 顺序表通常占优 |
理解这些权衡,才算真正理解数据结构选择。
十二、高频问答、验收清单与练习
12.1 高频问答
Q1:为什么通讯录可以直接复用顺序表?
因为顺序表管理的是一组同类型元素。把元素类型从 int 换成 PeoInfo,插入、删除、扩容和下标访问的结构规律并没有改变。
Q2:顺序表能存 100 人,是不是必须一开始申请 100 个位置?
不必。可以初始申请 100 个,也可以从 4 或 8 开始按倍数扩容。前者减少小规模扩容次数,后者让初始占用更小。
Q3:为什么查找联系人是 O(N),数组访问不是 O(1) 吗?
已知下标访问才是 O(1)。按姓名查找时并不知道下标,只能逐个比较;除非额外维护哈希索引或其他搜索结构。
Q4:删除联系人后要不要清空最后一个槽位?
逻辑上不需要。size 减一后,原最后位置已经不属于有效区间。若元素中含敏感数据或资源句柄,则可能需要主动擦除或执行析构式清理;当前纯值结构体没有这个要求。
Q5:fread 读到一半记录怎么办?
返回值不会是 1,加载循环停止。更完善的格式应校验文件长度、魔数、版本和记录数,并对损坏文件给出明确提示。
Q6:为什么不能用 feof 作为读取循环的先决条件?
因为 EOF 只有在读取动作越过文件结尾后才被置位。应先调用读取函数,再根据它的返回值判断是否得到完整数据。
Q7:动态顺序表尾插一定是 O(1) 吗?
单次操作不一定。触发扩容的那次尾插可能是 O(N);在按倍数扩容的前提下,连续尾插的均摊复杂度是 O(1)。
Q8:移除元素为什么不调用 SeqListErase?
循环删除会反复搬移后缀,最坏达到 O(N²)。快慢指针一次扫描就能完成同样的稳定压缩。
Q9:合并有序数组为什么从后往前?
因为 nums1 的空位在尾部。从尾部写入较大值,不会覆盖前部尚未比较的有效元素。
Q10:二进制文件一定比文本文件好吗?
不是。二进制直接写结构体简单、读写快,但可读性和兼容性差;文本或结构化格式更便于调试、迁移和版本演进。应根据目标选择。
12.2 项目验收清单

完成项目后,不妨逐项检查:
- 空通讯录能正常启动、展示和退出;
- 首次运行没有数据文件时不会崩溃;
- 添加超过初始容量后仍能正确扩容;
- 删除首元素、中间元素、尾元素都正确;
- 查找不存在联系人时不会访问
data[-1]; - 修改中途输入失败时,旧记录不会只改一半;
- 保存后重新启动,所有记录都能恢复;
- 所有
fopen都有对应fclose; - 所有动态空间最终都被释放;
- 手机号数组给 11 位号码和
\0留出了空间; - 扩容失败时不会丢失原指针;
- 菜单选择
0能安静退出,不误报输入错误。
12.3 动手练习
- 用静态顺序表实现同样的通讯录,比较容量边界与接口差异。
- 把
SeqListPushBack改为返回状态,让"添加成功"只在真正插入后打印。 - 使用
fgets重写输入模块,使地址能够包含空格。 - 为联系人增加唯一 ID,并支持按 ID 删除或修改。
- 增加姓名排序功能,思考排序后查找是否能使用二分查找。
- 在文件头写入魔数、版本号和记录数,并校验损坏文件。
- 使用临时文件保存,成功关闭后再替换正式数据文件。
- 为动态顺序表增加
Reserve和ShrinkToFit接口,观察空间变化。 - 为
removeElement编写包含空数组、全删除、无删除和交替元素的测试。 - 为
merge验证m=0、n=0、完全前置、完全后置和重复值场景。 - 统计在不同增长因子下,插入一万个元素发生的扩容次数和总拷贝量。
- 阅读下一讲单链表,重新实现通讯录,再比较两种容器的代码复杂度和运行特征。
总结
顺序表的真正价值,不在于背下几个接口,而在于看见一条清晰的应用链路:
用结构体描述业务数据 → 用动态顺序表组织记录 → 用业务函数完成增删查改 → 用文件读写跨运行周期保存状态。
通讯录项目还提醒我们,容器和业务应当分层。SeqList 只维护连续空间、容量和有效元素;Contact 负责姓名、电话以及用户动作;菜单只负责交互。职责越清楚,后续替换输入方式、文件格式甚至底层容器就越容易。
两道经典算法进一步展示了连续数组上的重要技巧:
- 移除元素使用快慢指针,一次扫描完成原地稳定压缩;
- 合并有序数组从后向前写,利用尾部空闲区避免覆盖未处理数据。
最后,顺序表的缺点同样来自它最重要的特点------连续存储。连续布局带来随机访问和缓存友好,也带来中间移动、整体扩容和预留空间。链表不是对顺序表的简单替代,而是针对另一组约束做出的权衡。
当你能够根据访问方式、插删位置、数据规模和空间约束选择结构,而不是只凭题目关键词套模板时,数据结构才真正从"课堂代码"变成了解决问题的工具。