☰
C语言顺序表实现通讯录:从动态扩容到文件持久化
2026/10/17 3:15:13 网站建设 项目流程

1. 项目概述与需求拆解

“基于顺序表实现通讯录”几乎是数据结构入门阶段绕不开的一个实战练习。它看起来简单,其实把线性表的存储设计、动态扩容、查找删除、排序、文件持久化全串了一遍,认真做下来收获不小。

简单说,顺序表就是在一块连续的内存里按顺序存放数据,像一排编号的储物柜,每个柜子里放一个联系人。通讯录则要求我们能往里加人、删人、按姓名找人、按号码找人,还能把数据存到文件里、下次启动再读出来。俩东西一结合,就是“用连续存储结构做一个可增删查改的联系人管理程序”。

这个题适合谁?刚学完数组想进阶的初学者,做课设需要参考框架的同学,以及想复习数据结构的开发者。我自己当初做这个题时走了不少弯路,尤其是扩容边界和文件格式这两块,卡了很久。这篇就按我自己重新实现时的思路,从设计到落地,到常见坑,一步步完整拆解一遍。

要实现一个能用的通讯录,不能上来就写代码,先把需求拆明白。我按功能做了一张表,做完这张表脑子里就有图了:

模块功能点要点说明
联系人管理添加、删除、修改联系人涉及顺序表插入删除、容量判断
查询按姓名查找、按电话查找顺序遍历或二分查找
排序按姓名/号码排序需要移动记录整体的交换
显示分页打印通讯录遍历输出
持久化保存到文件、从文件加载格式约定与二进制/文本选择
辅助功能统计人数、清空、退出释放内存、防止泄漏

添加联系人是核心操作,插入到表尾最简单,但如果要求“按姓名有序插入”,就得先找位置再移动元素。删除联系人要考虑删除后怎么搬动后续元素,不留下“空洞”。修改则相对简单,查到了直接改。

查询功能上,顺序查找最容易写,但数据量大时确实慢。如果通讯录按姓名排序了,可以考虑二分查找,不过要注意字符比较的细节。

数据显示这块容易被忽略。全量打印几百条联系人时控制台会刷屏,所以最好加分页,每页显示固定条数,通过按键翻页。只是练习题的话可以简化,一旦真有人手动录入了 500 条数据,你就知道分页有多重要了。

文件持久化是重头戏。通讯录数据断电就没意义了,必须能存,能读。文件格式我在后面会单独说,反正别用二进制存结构体,坑很多。

2. 核心数据结构设计与容量机制

2.1 联系人结构体到底该怎么定

结构体是顺序表元素的基础。定少了后续扩展费劲,定多了浪费空间。

最常见的简化版本长这样:

typedef struct { char name[32]; // 姓名 char phone[16]; // 电话 char email[64]; // 邮箱 } Contact;

实际做的时候我多加了一个字段,分组标记,也就是“家人”“朋友”“同事”这种,后面对分组查询很有帮助:

typedef struct { char name[32]; char phone[16]; char email[64]; int group; // 0-家人 1-朋友 2-同事 3-其他 } Contact;

为什么这些字段都用定长数组?因为顺序表要求元素定长,这样才能通过下标直接算出地址。如果每个联系人是 112 个字节(32+16+64+int),第 i 个联系人的地址就是 base + i * 112,访问效率高。

用变长字符串指针 char *name 也可以,但每个元素长度不一,要存指针数组,那本质上就变成指针数组了,失去了顺序表连续存储的意义。通讯录程序追求简单可控,定长数组更合适。

2.2 顺序表结构体的容量设计

很多人只定义联系人数组,忘了维护表本身的信息,结果越写越乱。一个规范的做法是单独定义一个顺序表结构体:

typedef struct { Contact *data; // 指向动态数组的指针 int length; // 当前联系人个数 int capacity; // 当前容量 } AddressBook;

这里的 capacity 和 length 是两个非常关键的概念。capacity 是当前最多能存多少个,length 是实际存了多少个。存满了要扩,删光了也不会自动缩,这一条后面专门讲。

为什么不直接定一个超大数组比如Contact data[10000]?因为空间浪费。这个程序在小数据集上跑没事,但你得知道动态容量的意义。顺序表的扩容是“按需分配、倍数增长”,初始容量和扩容策略直接影响内存使用效率。

2.3 扩容策略的等价说明

扩容最简单的做法:满了就申请一块新内存,把旧数据整体搬过去,释放旧内存。

void expandCapacity(AddressBook *book) { if (book->length < book->capacity) return; int newCapacity = book->capacity * 2; Contact *newData = (Contact *)malloc(sizeof(Contact) * newCapacity); if (newData == NULL) { printf("内存分配失败\n"); return; } // 搬数据 for (int i = 0; i < book->length; i++) { newData[i] = book->data[i]; } free(book->data); book->data = newData; book->capacity = newCapacity; }

容量为什么按 2 倍扩而不是每次只加 1?这里有个均摊分析。每次加 1,插入 n 条数据要复制 n-1 次,总时间复杂度 O(n²),非常不划算。按倍数扩,扩容次数约为 log n,均摊下来每个元素只多复制常数次,整体 O(n)。按 1.5 倍还是 2 倍都可以,实际工程里 1.5 倍更省内存,但 2 倍实现简单。练习题用 2 倍完全没问题。

初始容量设多少合适?我推荐 10 到 20,不要设 1 也不要设 1000。设太小导致频繁扩容,设太大如果只录入几个联系人就浪费了。10 是一个不错的起步值。

注意:扩容后旧指针已释放,所有指向旧 data 的指针都失效了。如果外层代码保存过Contact *p = &book->data[3],扩容后这个 p 就成了悬空指针。这个细节很隐蔽,我当年排查了半小时。

3. 各功能模块的实现思路与代码细节

3.1 添加联系人:插到尾部还是维护有序

通讯录添加联系人主要有两种思路:无脑插尾部,或者按姓名有序插入。

无脑插尾部最简单,length 位置直接赋值,然后 length++。但如果数据量大后要按姓名查找,顺序查找的效率会越来越低。

按姓名有序插入需要先找到插入位置,然后从后往前搬元素:

int insertByName(AddressBook *book, Contact contact) { if (book->length >= book->capacity) { expandCapacity(book); } int pos = book->length; // 找到第一个姓名比 contact.name 大的位置 while (pos > 0 && strcmp(book->data[pos - 1].name, contact.name) > 0) { pos--; } // 从 pos 到 length-1 全部后移一位 for (int i = book->length; i > pos; i--) { book->data[i] = book->data[i - 1]; } book->data[pos] = contact; book->length++; return pos; }

这里用中文姓名排序有个坑:strcmp是按字节比较的,中文编码下这不是拼音排序。如果真需要拼音排序,得先把姓名转成拼音或拼音首字母存一个字段。很多课设只需要能排序就够,不要求精确拼音,所以 strcmp 也能交差。

移动元素这个操作的时间复杂度是 O(n),插入一个联系人排在中间就得搬一半数据。顺序表天生如此,链表的插入更灵活,但查找又得遍历。在做项目选型时要能说清楚为什么这道题用顺序表而不链表:因为通讯录要频繁按索引随机访问,顺序表更合适。

3.2 删除联系人到底是先查找还是直接定位

删除操作分两种情况。已知下标,直接删;未知下标,先查找再删。

按下标删除的核心逻辑是:从下标 i 开始,把后面的元素逐个前移一位,然后把 length 减一。很多人写完忘记让最后一个位置“失效”,其实只是逻辑上不再访问它,下次覆盖写进去就行,不需要清空。但如果是删中间元素,记得最后一个位置要赋值,否则会残留重复数据。

按姓名删除要注意,可能不止一个人叫同一个姓名。比如通讯录里有两个“张三”,删一个还是全删?实际项目里一般约定:按姓名只删第一个匹配的,或者把所有匹配的都删掉。我建议做成“重复姓名确认删除”的交互,因为误删很难找回:

int deleteByName(AddressBook *book, const char *name) { int found = 0; for (int i = 0; i < book->length; i++) { if (strcmp(book->data[i].name, name) == 0) { // 找到第一个,前移覆盖 for (int j = i; j < book->length - 1; j++) { book->data[j] = book->data[j + 1]; } book->length--; found = 1; break; } } return found; }

如果要求删除所有同名联系人,可以改成 while 循环,每次都从开头查找,甚至用双指针原地覆盖一次遍历搞定。不过这属于优化,课设阶段可以先跑通再说。

3.3 查找联系人不能用 strcmp 一把梭

查找是通讯录使用最频繁的操作。按号码查找时,strcmp是最首选的方法,因为手机号不存在大小写、模糊匹配等复杂逻辑。

按姓名查找时要注意区分精确匹配和模糊匹配。精确匹配是名字一字不差,模糊匹配则是“输入一部分就能查出来”。实际用下来,模糊匹配体验更好,因为用户可能记不全名字。但模糊匹配意味着要遍历整个表,用strstr函数做子串匹配:

int searchByName(AddressBook *book, const char *keyword) { int count = 0; for (int i = 0; i < book->length; i++) { if (strstr(book->data[i].name, keyword) != NULL) { printf("%-8s %-15s %-30s\n", book->data[i].name, book->data[i].phone, book->data[i].email); count++; } } return count; }

模糊匹配可能匹配出一堆无关结果,比如搜“小”会出来“小明”“小红”“小军”,这不一定符合预期。现实中有人喜欢精确匹配,有人习惯模糊搜。最好在交互层让用户选择:按 1 精确查找,按 2 模糊查找。这样代码逻辑也更清晰。

如果联系人已经按姓名有序排列,可以考虑二分查找。但二分查找对中文姓名的排序要求较高,需要一致的排序规则。练习题里数据量一般就几百条,线性查找完全够用,别为了炫技引入二分然后把排序逻辑搞复杂了。

3.4 排序的稳定性比你想的重要

通讯录里按姓名排序的时候,如果两个联系人同名,顺序是否保持录入先后?这取决于排序算法是否稳定。C 标准库自带qsort,但它是不稳定排序:

int compareByName(const void *a, const void *b) { const Contact *ca = (const Contact *)a; const Contact *cb = (const Contact *)b; return strcmp(ca->name, cb->name); } qsort(book->data, book->length, sizeof(Contact), compareByName);

qsort的快排不稳定,同姓名的两个联系人换顺序了在通讯录场景下一般无所谓。但如果你的程序已经按录入时间隐式排了序,稳定性丢失就会让同名联系人顺序乱掉。想保稳定性可以自己写冒泡或者归并,但通讯录数据量小,冒泡的固定代价也不高。

我一直觉得排序这块重点不在算法本身,而在“排序过后,序号还准不准”。如果你显示联系人时带了序号,那序号是动态算的还是存进结构体里的?我遇到过一个同学,把序号存进了结构体,排序之后序号全乱了,查一个联系人找了半天。序号不该存进数据里,应该打印时用 i+1 计算。

3.5 文件保存:别用二进制直接写结构体

文件保存是很多初学者最容易翻车的环节。有人直接这样写:

fwrite(book.data, sizeof(Contact), book.length, fp);

如果这个项目只有你一个人用、换台电脑还是同样的编译器同样的系统,那这个写法能用。问题是,Contact里有char[32]这种定长数组,结构体有对齐填充字节,不同平台的填充规则可能不同。一旦换环境,这个文件可能读不出来。

我后来一直用文本格式保存,每条记录一行,字段用逗号或竖线分隔。比如 CSV 风格:

1,张三,13800138000,zhangsan@example.com 1,李四,13900139000,lisi@example.com

优点很明显:可以直接用文本编辑器打开查看,能用 Excel 打开,出问题很容易排查。缺点是解析稍微多几行代码,但这对 C 语言来说是基本功,值得练。

写入:

void saveToFile(AddressBook *book, const char *filename) { FILE *fp = fopen(filename, "w"); if (fp == NULL) { printf("无法打开文件 %s\n", filename); return; } for (int i = 0; i < book->length; i++) { fprintf(fp, "%d,%s,%s,%s\n", book->data[i].group, book->data[i].name, book->data[i].phone, book->data[i].email); } fclose(fp); printf("已保存 %d 条记录\n", book->length); }

读取:

void loadFromFile(AddressBook *book, const char *filename) { FILE *fp = fopen(filename, "r"); if (fp == NULL) { printf("首次运行或没有找到数据文件\n"); return; } while (fscanf(fp, "%d,%31[^,],%15[^,],%63[^\n]", &book->data[book->length].group, book->data[book->length].name, book->data[book->length].phone, book->data[book->length].email) == 4) { book->length++; if (book->length >= book->capacity) { expandCapacity(book); } } fclose(fp); printf("已加载 %d 条记录\n", book->length); }

%31[^,]这种写法是限制读取长度并遇到逗号停止,避免读到逗号本身。很多人写%s读 CSV,遇到逗号分隔就出错,比如名字里的逗号导致字段错位,这个细节一定要搞清楚。

注意:存储文件的第一行如果带表头,加载时要先用fscanf把表头吃掉。不然第一行数据会解析失败,又找不到原因。

4. 常见问题与排查技巧实录

4.1 越界访问:最隐蔽又最常见的死法

顺序表所有危险操作基本都指向同一个词:越界。访问data[-1]、访问data[length]、写入时越过 capacity,这些行为不会立刻报错,而是悄悄破坏内存,运行到某个无关函数时才突然崩溃。

我调试过的一个真实例子:通讯录存了 11 个人,capacity 是 10,向第 11 个位置写入时发生了越界。但当时程序没崩,因为那块内存刚好是可写的堆空间,数据覆盖了后面某个指针的值。等到后面调用 free() 释放内存时,整个程序直接段错误。

排查越界没有什么技巧,就是把 capacity 判断加严。插入前判断,删除后检查 length 不小于 0,查找循环条件是 i < length 而不是 i <= length。这是最基础的防线,也是最容易漏的防线。

4.2 scanf 的缓冲区残留问题

用scanf("%c")读取菜单选项时,如果之前用过scanf("%d")或scanf("%s"),回车键会残留在输入缓冲区,%c会把它当成有效输入读走,菜单就莫名闪过了。

解决方案要么在%c之前加一个空格写成scanf(" %c", &choice),要么用getchar()把残留的换行吃掉。我习惯用前者,代码更简洁。这个坑几乎每位新手都会踩一遍,完全可以通过提前了解来避免。

4.3 文件重复加载导致数据翻倍

有的程序启动时加载一次文件,菜单里又有“从文件导入”选项,用户如果误操作点两次,load 函数不会清空原表,而是把文件内容追加在原有数据后面,通讯录条目瞬间翻倍。

解决方法是加载前先执行一次clearBook(book),把 length 重置为 0。更稳妥的办法是在 load 函数开头判断if (book->length > 0)并提示用户是否覆盖。不要小看这个小细节,我做项目复盘时发现,真实用户的误操作率比你想象的高得多。

4.4 释放内存与重复释放

程序退出前要释放堆内存:

void destroyBook(AddressBook *book) { free(book->data); book->data = NULL; book->length = 0; book->capacity = 0; }

free 后把指针置空是很好的习惯。如果你在使用倍增扩容时先把新数据 malloc 出来,再 free 旧数据,顺序别搞反,不然旧数据还没复制就被你释放了,后面读到的全是野值。

我基本都会设置退出确认。因为用户按了退出,程序要问一句“确定要退出吗?未保存的数据将丢失”。这不需要多写几行,但对体验的提升很大,很多同学写完没有这个意识。

4.5 关于“容量缩水”和处理策略

删除联系人后,顺序表的 length 减少了,但 capacity 不会自动缩小。比如 capacity 是 1024,删了 1000 个联系人,capacity 依然是 1024,内存并没有还回去。这在大型项目里会造成浪费,但在通讯录这级项目里基本无所谓。

如果真要做缩容,可以在 length 小于 capacity 的四分之一时缩到一半,核心是避免抖动。缩容前同样要判断新容量不能小于初始容量。这个逻辑不复杂,但留作扩展功能更合适,毕竟通讯录场景数据量一般不大。

5. 优化方向与后续扩展

5.1 从顺序表过渡到“支持按姓名索引”

通讯录数据量大了以后,顺序查找按姓名会明显变慢。一个自然的优化是引入散列表或索引表。如果是课设阶段,可以做一个额外索引数组保存每个联系人的下标,按姓名排序后做二分查找,只交换索引下标而不移动大数据。好处是排序快得多,坏处是增加了维护索引的复杂度。

不过这个阶段我更推荐把基础功能写稳,不要过早引入复杂结构。顺序表本身就是核心考点,先把插入、删除、查找、排序这几个操作在顺序表上的时间复杂度、空间复杂度说得明明白白,比堆功能有用得多。

5.2 导出与导入的格式兼容

CSV 导出非常实用,因为很多表格软件可以直接打开。也就是说,通讯录在 C 程序里维护,但数据可以通过 Excel 批量录入后再导入,这在真实场景里很受用。

CSV 本身会存在字段中包含逗号的问题。如果手机号、邮箱里不会出现逗号,那么分隔符用逗号没问题;如果字段可能包含逗号,可以改用竖线|或制表符\t。保存数据时也建议统一字符编码,Windows 下中文文本保存为 UTF-8 会和记事本兼容性较差,写文件时注意编码声明或用 GBK。这里的坑主要来自系统环境,不是纯逻辑问题。

5.3 给初学者的练手建议

如果你刚拿到这个题,我的建议是:不要一上来就写完整菜单框架,先把“添加联系人”“打印联系人”打通,再逐步加删除、查找、保存。每加一个功能跑一轮测试,把数据推入边界场景,比如插入满扩容、删除空表、查找不存在的人,确保不会崩。

我还喜欢加一个测试函数,自动插入 105 条测试数据,验证扩容超过 5 次的边界。这种测试习惯对后面做复杂项目很有帮助,是真实工程里值得保留的素养。别人可能觉得你多花了几分钟,但对接下来的调试节省了数倍时间。

6. 实操总结与我的个人体会

我做完这个项目最大的体悟是:顺序表的代码并不复杂,难的是“边界意识”。什么时候扩容、什么时候越界、什么时候文件读不完整,这些在教科书里写得很轻描淡写,真正跑起来全是从错误教训里换来的。

有一个细节值得单独再说一次,就是结构体数组整体赋值的问题。我自己早期写移动元素时直接用book->data[j] = book->data[j+1],后来意识到 Contact 结构体如果内部包含指针,这就会蜕变成浅拷贝,新旧元素会共享同一块内存,修改哪一个都会影响另一个。幸好这个练习里所有字段都是定长数组,浅拷贝没出问题。但如果你之后改造成了动态字符串,就一定要关注深拷贝的问题。

另外,交互逻辑建议尽量确认化。删除之前确认一次,退出之前确认一次,覆盖文件之前确认一次。这些确实多写了几个分支,但实际使用的用户会觉得这软件有“人情味”。

这个项目做完整理一下,你会发现自己对指针、内存、结构体、文件操作的理解会舒服很多。如果做完感觉“不过如此”,你不妨把这些代码拿给同学 review 一遍,看他能不能挑出毛病。很多时候,自己的代码只有被别人问“这里为什么这么写”时,才会发现自己并没想透所有细节。

我自己后来又基于这个通讯录扩展了一个按首字母分组统计的展示模式,本质上就是一个简易的索引分组逻辑,花了一下午,但对顺序表的理解又深了一层。这个方向同样推荐给你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询