先说个建议:不管你是正在上数据结构课,还是在准备考研、刷面试题,顺序表都值得你亲手做一个完整的项目,而不是只会在卷子上画图。很多人觉得顺序表不就是数组嘛,初始化、插入、删除、查找,背一遍就行了。但真等你自己动手写一个通讯录,把这些操作全部调通,你才会发现里面藏着大量细节:动态扩容怎么扩、删除之后要不要释放空间、为什么函数传参传的是二级指针而不是一级指针、插入一个元素为什么要从后往前挪。这篇文章就是围绕“数据结构 + 顺序表 + 通讯录项目实战”这条线,把顺序表的核心原理讲透,然后带你把通讯录管理系统的核心功能一步步写出来。本文按上篇定位,先把顺序表底层和增删查改做完,排序、文件持久化、联系人分组这些扩展放到下篇。
1. 项目整体设计与思路拆解
1.1 需求拆解:通讯录到底要哪些功能
我习惯在写代码前先列需求,哪怕是一个练手项目。通讯录的核心需求很直白:能存联系人、能添加联系人、能删除联系人、能按名字找联系人、能修改联系人信息、能看到当前所有联系人。如果再往下拆一点,联系人至少要包含姓名和电话号码,如果想让它更像真实管理系统,可以再加一个备注字段。
但这里有个很关键的问题:这些功能的核心逻辑,和顺序表有什么关系?我换个说法你就明白了。“存一批联系人”这件事,本质就是“在一个线性序列里维护若干元素”;添加联系人就是顺序表的插入操作,删除联系人就是顺序表的删除操作,按名字找联系人是按值查找,显示全部联系人就是遍历。所以通讯录不是一个独立项目,它只是顺序表的一层业务外衣。先想清楚这一点,你就知道做这个项目时,代码应该分成两层:底层是顺序表对数据的增删查改,上层是通讯录对联系人信息的业务处理。
我在准备这个项目时,功能清单大致是这样的:
| 功能 | 用户操作 | 底层对应 |
|---|---|---|
| 添加联系人 | 输入姓名、电话、备注,存到末尾 | 顺序表插入(尾插) |
| 删除联系人 | 按姓名删除指定联系人 | 按值查找 + 按下标删除 |
| 查找联系人 | 输入姓名,输出完整信息 | 按值查找 |
| 修改联系人 | 输入原姓名和新资料,覆盖原记录 | 按下标修改 |
| 显示全部 | 打印当前所有联系人 | 顺序表遍历 |
单看功能,确实没有涉及排序、分组、分页这些高级东西。但如果你是初学者,我建议先把这一版跑通,再考虑扩展。基础都不牢的时候,加一堆花里胡哨的菜单只会让你分心。
1.2 为什么选顺序表,而不是链表或直接裸写数组
很多初学者会问:顺序表不就是结构体包了层数组吗,干嘛不直接开一个 Contact contacts[100] 算了?这个想法我能理解,但通讯录里的人数是可变的,今天可能是20人,过一个月可能变成200人。如果你直接开固定数组,要么浪费空间,要么一旦超过上限,程序就崩给你看。顺序表的价值就在于它替你管理了“容量”这个概念,满了就扩容,删了也能通过 size 精确控制有效元素数量,而不是靠一个写死的数值硬撑。
那链表行不行?按原理说当然行,很多学校的通讯录作业也会用链表实现。但顺序表在这个场景里有一个非常明显的优势:通讯录操作的高频动作不是频繁插入和删除,而是查看、查找、修改。比如“我存了300个联系人,想看一眼第100个人是谁”,顺序表用下标一步就能定位,链表的随机访问要一个个往后找。而且顺序表底层是连续内存,CPU缓存友好,遍历访问速度比链表快得多。所以通讯录这个需求,选顺序表更贴近真实应用场景。
另外,从学习顺序上讲,顺序表是所有线性表里面最容易理解的一种。它先不讲指针怎么绕来绕去,核心就是一个动态数组。你把顺序表搞懂了,再去看链表里的 malloc、指针操作,会轻松很多。这也是为什么绝大多数教材,从严蔚敏老师的《数据结构》到考研用的王道资料,都把顺序表放在线性表的第一站。
1.3 代码组织:一个文件和一个关键前提
这个项目我用纯 C 语言写,选 C 是因为教科书的算法描述基本都是 C 语言的伪代码,你移植起来没有任何隔阂。当然,用 Java、Python 的同学也完全能看懂,顺序表的核心思路是通用的,只是语法层面有差异。
我建议你把它写成一个单文件工程,比如 contact_seqlist.c,里面分成几块:底层顺序表的 init、insert、delete、find、update 等函数,上层业务菜单的 addContact、deleteContact、findContact 等封装,最后是 main。项目不大,不用急着拆头文件和源文件,把数据流向理清楚比什么都重要。
这里有一个关键前提必须声明:为了简化代码逻辑,这个版本的通讯录我假设姓名是唯一的,查找、删除、修改都按姓名来。如果你要把功能做得更完善,可以自己给联系人加一个自增 ID,用 ID 做唯一键。这个扩展留到后面再说。
2. 顺序表核心原理解读:背概念前先搞懂地址不变量
2.1 顺序表的物理存储:连续内存是你的第一直觉
顺序表的定义很多人都会背:用一段地址连续的存储单元,依次存放线性表中的数据元素。用大白话说,它底层就是一个数组。数组这个名字大家太熟了,以至于很多人忽略了它最有价值的特征——随机访问。
数组能随机访问,前提是你知道元素的首地址和每个元素的大小。假设数组首地址是 base,每个元素占 sizeof(ElemType) 个字节,那么第 i 个元素的地址就是:
text复制base + i * sizeof(ElemType)
这个公式对应到 C 语言里就是 list->data[i]。编译器看到 data[i],做的其实就是一次地址运算,然后去那块内存取值。所以顺序表的“按下标访问是 O(1)”,不是数学上的描述,而是物理上的必然。
这就直接引出了顺序表第一个设计点:结构体里必须同时存三样东西——数据区首地址 data、当前有效元素个数 size、当前最大容量 capacity。只有 data 和 size 是不够的,因为数组本身不知道什么时候满;只有 data 和 capacity 也没有意义,因为你不知道现在存了几个有效元素。很多代码写崩,就是因为没有把 size 和 capacity 分清楚,插入的时候拿 capacity 当有效长度,一插就歪。
2.2 动态扩容机制:容量和大小必须分开看
如果顺序表内部是一块静态数组,那它跟裸写数组没有本质区别。真正让顺序表“活”起来的是动态扩容。下面这段代码是每次扩容时的核心思路:
c复制static int SeqListExpand(SeqList *list) {
if (list == NULL || list->
