简介:这份资源是面向计算机专业学生的数据结构课程设计配套代码,主题为通讯录管理系统,采用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 × (4+32+16+16+4) ≈ 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置 1,next_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验证结果。这样在答辩前改代码的时候,跑一遍测试就能知道有没有改坏东西。希望帮到你。
本文还有配套的精品资源,点击获取