资讯动态

C语言顺序表实现通讯录:从动态扩容到文件持久化

发布时间:2026/10/17 3:15:20 来源:尧图企业网站定制
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 个字节321664int第 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的快排不稳定同姓名的两个联系人换顺序了在通讯录场景下一般无所谓。但如果你的程序已经按录入时间隐式排了序稳定性丢失就会让同名联系人顺序乱掉。想保稳定性可以自己写冒泡或者归并但通讯录数据量小冒泡的固定代价也不高。我一直觉得排序这块重点不在算法本身而在“排序过后序号还准不准”。如果你显示联系人时带了序号那序号是动态算的还是存进结构体里的我遇到过一个同学把序号存进了结构体排序之后序号全乱了查一个联系人找了半天。序号不该存进数据里应该打印时用 i1 计算。3.5 文件保存别用二进制直接写结构体文件保存是很多初学者最容易翻车的环节。有人直接这样写fwrite(book.data, sizeof(Contact), book.length, fp);如果这个项目只有你一个人用、换台电脑还是同样的编译器同样的系统那这个写法能用。问题是Contact里有char[32]这种定长数组结构体有对齐填充字节不同平台的填充规则可能不同。一旦换环境这个文件可能读不出来。我后来一直用文本格式保存每条记录一行字段用逗号或竖线分隔。比如 CSV 风格1,张三,13800138000,zhangsanexample.com 1,李四,13900139000,lisiexample.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[j1]后来意识到 Contact 结构体如果内部包含指针这就会蜕变成浅拷贝新旧元素会共享同一块内存修改哪一个都会影响另一个。幸好这个练习里所有字段都是定长数组浅拷贝没出问题。但如果你之后改造成了动态字符串就一定要关注深拷贝的问题。另外交互逻辑建议尽量确认化。删除之前确认一次退出之前确认一次覆盖文件之前确认一次。这些确实多写了几个分支但实际使用的用户会觉得这软件有“人情味”。这个项目做完整理一下你会发现自己对指针、内存、结构体、文件操作的理解会舒服很多。如果做完感觉“不过如此”你不妨把这些代码拿给同学 review 一遍看他能不能挑出毛病。很多时候自己的代码只有被别人问“这里为什么这么写”时才会发现自己并没想透所有细节。我自己后来又基于这个通讯录扩展了一个按首字母分组统计的展示模式本质上就是一个简易的索引分组逻辑花了一下午但对顺序表的理解又深了一层。这个方向同样推荐给你。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑