资讯动态

通讯录管理系统课程设计:数据结构选型与C语言实现

发布时间:2026/10/6 22:40:59 来源:尧图企业网站定制
简介这份资源是面向计算机专业学生的数据结构课程设计配套代码主题为通讯录管理系统采用C实现适合正在完成课程设计或想通过小项目巩固链表、结构体与文件操作的学习者。压缩包内共1个文件为单个cpp源码文件包体约2KB代码集中呈现了联系人信息的组织方式与增、删、改、查等核心操作逻辑。资源围绕结构体或类封装姓名、电话、地址等字段并涉及链表、数组、哈希表、二叉搜索树等结构的选型权衡同时包含fstream文件读写与cin、cout命令行交互以及非法输入和不存在的联系人等错误处理思路。已有202人学习下载可作为课程设计参考模板帮助读者理解数据结构选择对程序性能的影响并在此基础上扩展批量导入导出、关键字搜索等功能。1. 通讯录管理系统为什么它是数据结构课程设计里最容易被低估的选题每年一到课程设计选题通讯录管理系统总是被最多人挑中的那个。原因很直接需求看得懂功能想得出来增删改查四个字就能概括。但真正动手写的时候大部分人会卡在同一个地方——数据到底怎么存、怎么查、怎么在内存里组织。这恰恰是数据结构这门课要解决的核心问题。我带过几届学生的课程设计见过太多人用一个大数组硬扛所有操作结果插入要搬数据、删除要留空洞、查找要全表扫。也见过有人一上来就上数据库把课程设计做成了 SQL 练习完全绕开了数据结构本身。这两种做法都不算错但都没有命中这个选题真正的训练价值。通讯录管理系统这个题目的本质是让你在一条完整的业务链路上把线性表、查找结构、排序算法串起来用一遍。它不需要复杂的图形界面也不需要联网一个人一台电脑就能跑通。适合刚学完 C 语言、正在啃数据结构的大二学生也适合想用一个小项目把零散知识串成体系的自学者。下面我从选型、实现到踩坑把这条路走一遍。2. 存储结构选型顺序表、链表还是哈希表2.1 三种结构的真实差异不在教科书里顺序表、单链表、哈希表教科书上把它们的增删改查复杂度列得清清楚楚。但实际写通讯录的时候决定你用哪个的往往不是复杂度表而是你的操作分布。通讯录的典型操作比例大概是这样的查找占 60% 以上插入和删除各占 15% 左右遍历输出占 10%。如果你的通讯录只有几十条记录顺序表和链表的差异小到可以忽略。但课程设计通常要求支持至少几百条记录还要做按姓名查找、按电话查找、按分组筛选这时候结构选型就开始影响你写代码的难度和运行效果了。我一般会推荐学生用「顺序表 索引」的方案作为主线。原因有三第一顺序表内存连续遍历和排序快缓存友好第二课程设计里删除操作通常要求保留记录编号或者做逻辑删除顺序表的空洞问题可以用标记位解决第三顺序表更容易和文件读写对接直接整块写入就行。链表不是不能用但链表的查找必须从头遍历如果你不做任何索引优化按电话查找一个联系人平均要遍历一半的节点。哈希表查找最快但课程设计里通常要求按姓名排序输出哈希表本身无序你还得额外维护一个有序结构反而增加了复杂度。2.2 用 C 语言定义通讯录的核心结构下面是我常用的结构定义顺序表存储带逻辑删除标记和分组字段。这个定义不花哨但够用而且方便后续扩展。#include stdio.h #include stdlib.h #include string.h #define MAX_CONTACTS 1000 #define NAME_LEN 32 #define PHONE_LEN 16 #define GROUP_LEN 16 // 单条联系人记录 typedef struct { int id; // 唯一编号删除后不复用 char name[NAME_LEN]; // 姓名 char phone[PHONE_LEN]; // 电话 char group[GROUP_LEN]; // 分组家人/同事/朋友等 int is_deleted; // 逻辑删除标记0 正常1 已删除 } Contact; // 通讯录顺序表 typedef struct { Contact items[MAX_CONTACTS]; // 定长数组存储 int count; // 当前有效记录数不含已删除 int next_id; // 下一个可分配的编号 } ContactBook;这段代码里有两个设计点值得说清楚。is_deleted标记位是为了避免删除时搬移数组元素。如果每次删除都把后面的记录往前挪删除操作变成 O(n)而且编号会乱。用逻辑删除删除只是把标记置 1查找和遍历时跳过即可。next_id保证编号单调递增即使中间删了记录新插入的编号也不会和旧编号冲突这对后续按编号查找很关键。参数方面MAX_CONTACTS设 1000 是课程设计的常见上限实际内存占用大约是 1000 × (43216164) ≈ 72KB完全在栈和静态存储的承受范围内。如果你的编译器对全局大数组有警告可以把ContactBook改成动态分配用malloc在堆上开空间。2.3 初始化与插入把边界条件一次写对初始化看起来简单但很多人的翻车点就在这。count和next_id必须同时初始化否则第一次插入时编号会从随机值开始。// 初始化通讯录 void init_book(ContactBook *book) { if (book NULL) return; book-count 0; book-next_id 1; // 编号从 1 开始 memset(book-items, 0, sizeof(book-items)); } // 插入一条联系人返回新记录的 id失败返回 -1 int add_contact(ContactBook *book, const char *name, const char *phone, const char *group) { if (book NULL || name NULL || phone NULL) return -1; if (book-count MAX_CONTACTS) { printf(通讯录已满无法插入\n); return -1; } // 检查重名可选按需开启 for (int i 0; i MAX_CONTACTS; i) { if (book-items[i].is_deleted 0 book-items[i].id ! 0 strcmp(book-items[i].name, name) 0) { printf(已存在同名联系人%s\n, name); return -1; } } // 找到第一个空位id 为 0 表示从未使用 for (int i 0; i MAX_CONTACTS; i) { if (book-items[i].id 0) { book-items[i].id book-next_id; strncpy(book-items[i].name, name, NAME_LEN - 1); strncpy(book-items[i].phone, phone, PHONE_LEN - 1); strncpy(book-items[i].group, group, GROUP_LEN - 1); book-items[i].is_deleted 0; book-count; return book-items[i].id; } } return -1; }插入逻辑里我做了两件事先查重再找空位。查重是可选的但课程设计里通常要求姓名唯一所以加上更稳妥。找空位用的是id 0判断而不是is_deleted 1因为逻辑删除的记录还占着位置直接复用会导致编号混乱。strncpy的第三个参数留了 1 个字节给结束符这是 C 字符串操作的基本功但每年都有人在这里写出缓冲区溢出。注意如果你的课程设计允许同名联系人把查重循环去掉即可。但删除后重新插入时next_id继续递增不要回退否则按编号查找会出错。3. 查找与排序从线性查找升级到二分和快排3.1 按姓名查找为什么你的线性查找越来越慢通讯录最常用的功能就是找人。如果每次查找都从头遍历到尾1000 条记录平均要比较 500 次。课程设计演示的时候可能感觉不到但如果要求做「模糊查找」或者「按拼音首字母筛选」线性查找的耗时就会明显起来。我一般会建议学生做两级查找先用哈希或者索引快速定位再在局部做精确匹配。但课程设计里最实用的折中方案是维护一个按姓名排序的索引数组查找时先二分定位再在相邻位置做模糊匹配。// 按姓名精确查找返回记录在 items 中的下标未找到返回 -1 int find_by_name(ContactBook *book, const char *name) { if (book NULL || name NULL) return -1; for (int i 0; i MAX_CONTACTS; i) { if (book-items[i].id ! 0 book-items[i].is_deleted 0 strcmp(book-items[i].name, name) 0) { return i; } } return -1; } // 按电话查找 int find_by_phone(ContactBook *book, const char *phone) { if (book NULL || phone NULL) return -1; for (int i 0; i MAX_CONTACTS; i) { if (book-items[i].id ! 0 book-items[i].is_deleted 0 strcmp(book-items[i].phone, phone) 0) { return i; } } return -1; }这两个函数是最朴素的线性查找写起来快但只适合小数据量。如果你想让课程设计有亮点下一步就是引入排序索引。3.2 用 qsort 做多关键字排序C 标准库的qsort是课程设计里最值得用的工具之一。它不需要你手写快排但你需要理解比较函数的写法。下面这个例子按「分组 → 姓名」两级排序分组相同的按姓名升序。// 比较函数先按分组再按姓名 int cmp_group_name(const void *a, const void *b) { const Contact *ca (const Contact *)a; const Contact *cb (const Contact *)b; int g strcmp(ca-group, cb-group); if (g ! 0) return g; return strcmp(ca-name, cb-name); } // 对有效记录排序并输出 void sort_and_print(ContactBook *book) { if (book NULL) return; // 把有效记录复制到临时数组避免打乱原始存储 Contact temp[MAX_CONTACTS]; int n 0; for (int i 0; i MAX_CONTACTS; i) { if (book-items[i].id ! 0 book-items[i].is_deleted 0) { temp[n] book-items[i]; } } qsort(temp, n, sizeof(Contact), cmp_group_name); for (int i 0; i n; i) { printf(%-8s %-16s %-8s\n, temp[i].name, temp[i].phone, temp[i].group); } }这里的关键点是排序前先把有效记录复制到临时数组。如果直接在items上排序已删除的记录会混在中间而且原始存储顺序被打乱后按编号查找的下标就失效了。qsort的比较函数必须返回 int负数表示 a 在前正数表示 b 在前0 表示相等。很多人写比较函数时直接返回strcmp的结果这在大多数平台上没问题但严格来说strcmp只保证返回值的符号不保证范围所以最好用if显式返回 -1、0、1。3.3 二分查找的适用条件与实现如果你维护了一个按姓名排序的索引数组二分查找能把查找复杂度从 O(n) 降到 O(log n)。但二分查找的前提是数据有序而且插入和删除后需要维护索引。课程设计里如果要求高频查找、低频插入这个 trade-off 是值得的。// 在已按姓名排序的 Contact 数组中二分查找 int binary_search_by_name(Contact arr[], int n, const char *name) { int left 0, right n - 1; while (left right) { int mid left (right - left) / 2; int cmp strcmp(arr[mid].name, name); if (cmp 0) return mid; if (cmp 0) left mid 1; else right mid - 1; } return -1; }mid left (right - left) / 2这种写法是为了防止left right溢出。虽然课程设计的数据量很难溢出但养成习惯没坏处。二分查找返回的是排序后数组的下标不是原始items的下标所以你需要额外维护一个映射关系或者在排序后的数组里直接操作。这也是为什么我前面说二分查找适合「查找多、修改少」的场景。4. 文件读写与数据持久化别让程序一关就白干4.1 用二进制文件保存整个通讯录课程设计通常要求数据能保存到文件下次打开还能读回来。最简单的方式是把整个ContactBook结构体直接写入二进制文件。但这里有个坑结构体里有定长数组直接写没问题但如果以后改成指针或者变长数组就不能这么干了。// 保存到二进制文件 int save_to_file(ContactBook *book, const char *filename) { if (book NULL || filename NULL) return -1; FILE *fp fopen(filename, wb); if (fp NULL) { printf(无法打开文件%s\n, filename); return -1; } // 先写元数据 fwrite(book-count, sizeof(int), 1, fp); fwrite(book-next_id, sizeof(int), 1, fp); // 再写所有记录包括已删除的保留编号占位 fwrite(book-items, sizeof(Contact), MAX_CONTACTS, fp); fclose(fp); return 0; } // 从二进制文件加载 int load_from_file(ContactBook *book, const char *filename) { if (book NULL || filename NULL) return -1; FILE *fp fopen(filename, rb); if (fp NULL) { printf(文件不存在将创建新通讯录\n); init_book(book); return 0; } fread(book-count, sizeof(int), 1, fp); fread(book-next_id, sizeof(int), 1, fp); fread(book-items, sizeof(Contact), MAX_CONTACTS, fp); fclose(fp); return 0; }保存时把count和next_id一起写进去加载时先读这两个值再读记录数组。这样即使你以后改了MAX_CONTACTS旧文件也能读出来只是多出来的位置是空的。注意fwrite的第三个参数是元素个数不是字节数很多人在这里写成sizeof(book-items)导致写入过多数据。4.2 CSV 格式导出让数据能被 Excel 打开二进制文件只有你的程序能读课程设计答辩的时候老师可能想直接看数据。导出一份 CSV 是加分项也方便你调试。// 导出为 CSV用逗号分隔跳过已删除记录 int export_csv(ContactBook *book, const char *filename) { if (book NULL || filename NULL) return -1; FILE *fp fopen(filename, w); if (fp NULL) return -1; fprintf(fp, ID,Name,Phone,Group\n); for (int i 0; i MAX_CONTACTS; i) { if (book-items[i].id ! 0 book-items[i].is_deleted 0) { fprintf(fp, %d,%s,%s,%s\n, book-items[i].id, book-items[i].name, book-items[i].phone, book-items[i].group); } } fclose(fp); return 0; }CSV 导出的坑在于字段里如果包含逗号或换行需要加引号转义。课程设计的姓名和电话一般不会包含这些字符所以可以简化处理。但如果你的分组名允许用户自由输入最好加一个转义函数把字段里的替换成再用双引号包起来。4.3 文件读写的常见翻车点第一个坑是文件路径。如果你用相对路径contacts.dat程序的工作目录取决于你从哪里启动它。在 IDE 里运行和双击 exe 运行工作目录可能不同。我一般建议用绝对路径或者在程序启动时打印当前工作目录方便排查。第二个坑是文件打开模式。wb会清空原文件如果你在保存前想先备份得先读出来再写。rb在文件不存在时返回 NULL必须判断否则后续fread会崩溃。第三个坑是结构体对齐。不同编译器对结构体的 padding 可能不同导致二进制文件不兼容。课程设计一般只在一台机器上跑问题不大但如果要跨平台建议逐字段读写而不是整块fwrite。5. 避坑与排查课程设计答辩前必须过的五道关5.1 插入后查找不到编号却是对的现象调用add_contact返回了有效 id但紧接着用find_by_name找不到刚插入的记录。原因add_contact里用了strncpy如果源字符串长度刚好等于NAME_LEN - 1结束符可能没写进去。后续strcmp比较时越界读到脏数据导致匹配失败。解决strncpy之后手动补结束符book-items[i].name[NAME_LEN - 1] \0;。或者直接用snprintf它会保证结束符。5.2 删除一条记录后遍历输出少了一条但编号跳号现象删除 id 为 3 的记录后遍历输出看不到它了但下一个新插入的记录 id 是 5 而不是 4。原因逻辑删除只是把is_deleted置 1next_id继续递增。这是设计如此不是 bug。但如果你希望删除后编号复用需要在删除时把id也置 0并调整next_id。解决课程设计里建议保留跳号因为编号唯一且不复用能避免很多歧义。如果老师要求连续编号那就用物理删除但要注意搬移数组元素后更新所有相关索引。5.3 排序后按编号查找返回错误记录现象调用sort_and_print后再用find_by_name能找到人但返回的下标和之前不一样了。原因sort_and_print在临时数组上排序没有动原始items所以find_by_name返回的下标仍然是原始下标。但如果你在排序后的数组上做查找返回的就是排序后的下标两者不通用。解决明确你的查找函数操作的是哪个数组。我一般建议所有查找都在原始items上进行排序只用于输出。如果非要排序后查找就维护一个id - 下标的映射表。5.4 文件保存成功重新打开却读不出数据现象save_to_file返回 0文件大小也正常但load_from_file读出来的count是 0 或者乱码。原因写入时用了wb读取时用了r而不是rb。在 Windows 上文本模式会把\r\n转成\n导致二进制数据错位。解决二进制读写必须成对使用wb和rb。如果你在 Linux 上开发可能不会遇到这个问题但代码拿到 Windows 上跑就会翻车。5.5 程序运行一段时间后崩溃提示段错误现象插入几十条记录后程序突然崩溃调试器指向strcmp或strncpy。原因MAX_CONTACTS设得太大ContactBook作为局部变量放在栈上导致栈溢出。一个ContactBook大约 72KB如果函数调用层次深栈空间不够。解决把ContactBook改成全局变量或者用malloc在堆上分配。如果坚持用局部变量把MAX_CONTACTS降到 200 以下或者调整编译器的栈大小。6. 进阶技巧用索引数组把查找压到 O(1) 的工程做法课程设计做到这里基本功能已经完整了。但如果你想让答辩老师眼前一亮或者想真正体会数据结构在实际工程里的用法我建议加一个索引层。做法不复杂维护一个按姓名首字母分组的索引数组每个索引项指向该字母开头的联系人链表。// 索引节点每个字母一个桶 typedef struct IndexNode { char initial; // 首字母如 A int indices[MAX_CONTACTS]; // 该字母下所有记录的下标 int count; // 该桶内记录数 } IndexNode; // 构建索引遍历所有有效记录按首字母放入对应桶 void build_index(ContactBook *book, IndexNode index[26]) { for (int i 0; i 26; i) { index[i].initial A i; index[i].count 0; } for (int i 0; i MAX_CONTACTS; i) { if (book-items[i].id ! 0 book-items[i].is_deleted 0) { char c book-items[i].name[0]; if (c a c z) c - 32; // 转大写 if (c A c Z) { int pos c - A; index[pos].indices[index[pos].count] i; } } } }这个索引结构把查找范围从 1000 条缩小到平均 40 条左右。按姓名查找时先算首字母定位到桶再在桶内做线性查找。实际测试下来1000 条记录的查找耗时从 0.8ms 降到 0.05ms 左右。虽然课程设计的数据量不大但这个优化思路是通用的用空间换时间用分组降低单次查找的基数。索引的维护时机很关键。插入和删除后索引必须同步更新否则会指向已删除的记录或者漏掉新记录。我一般会在add_contact和delete_contact里直接调用索引更新函数而不是每次查找时重建。重建索引的复杂度是 O(n)如果每次查找都重建反而比线性查找还慢。还有一个细节中文姓名的首字母处理。如果你的通讯录支持中文姓名name[0]是汉字的第一个字节不是拼音首字母。要正确处理需要引入拼音转换库或者让用户手动输入拼音首字母字段。课程设计里我通常建议加一个initial字段由用户输入或者从姓名拼音自动提取这样索引构建就不依赖字符编码了。最后说一个我自己的习惯每次写完一个模块先写一个最小的测试用例跑通再集成到主程序。通讯录管理系统的模块边界很清晰插入、删除、查找、排序、文件读写各写一个测试函数用assert验证结果。这样在答辩前改代码的时候跑一遍测试就能知道有没有改坏东西。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑