简介:面向数据结构课程设计场景,这套 C 语言实现方案围绕多级菜单组织单链表、栈、队列、二叉树与图五类结构的基本操作及典型应用,适合正在完成课设、需要参考模块划分与接口设计的高校学生。包内共 28 个文件,约 500KB,以 .h 头文件承载链表、栈、队列、二叉树、图及菜单声明,配合 .cpp 源文件与 .sln、.vcxproj 工程文件构成可直接打开的 Visual Studio 项目,另含 exe、pdb、ilk 等编译产物便于对照运行结果。已有 1714 人学习下载,说明其组织方式具有一定参考价值。内容覆盖一元多项式、通讯录、表达式求值、酒店客房分配、二叉树遍历与 Huffman 编码、图的邻接矩阵/邻接表、深度广度遍历、拓扑排序等课设常见题目,可作为功能拆分、菜单交互和调试排错的对照样本。
1. 别急着抄模板:一份 C 语言数据结构课设真正值得拆的是什么
课设题目发下来那天,多数人的第一反应是去搜一份现成的数据结构课程设计源码,能编译、能演示、能交差就行。真到了答辩现场,被问一句“你这个单链表删除节点,前驱指针是怎么拿到的”,往往就卡住了。这份 C 语言实现的数据结构课设,价值不在“能跑”,而在于它把线性表、栈与队列、二叉树、图、查找与排序这些课本上分散的 ADT,串进了一个带文件持久化和菜单交互的完整程序里:数据能存能读,操作有入口有出口,出错有返回值。刚学完指针、要交课设的人能照着跑通并讲明白;工作几年想把基础重新捋一遍的人,也能从中看到接口分层和内存管理这些平时被高级语言挡住的东西。
2. 工程骨架:从 main.c 到 ADT 分层,课设怎么组织才不像一锅粥
一堆课设代码之所以改不动,根子几乎都在组织方式上:所有函数塞进一个 main.c,全局变量满天飞,链表节点定义和菜单打印混在一起。想加一个功能,得先把两千行通读一遍。真正好改的版本,一定是先把“数据长什么样”和“界面怎么问”这两件事拆开,让它们中间隔一层接口。
2.1 目录结构与编译单元划分
我一般会按“一个数据结构一个编译单元”来切,src/下每个.c只负责自己那套结构的增删改查,include/放对外暴露的头文件,main.c只做菜单和调度。这样切的好处很直接:改链表不用重新理解排序,测排序不用先造一堆学生记录。
| 路径 | 职责 | 会不会经常改 |
|---|---|---|
include/list.h | 线性表 ADT 与 Item 记录定义 | 少 |
include/stack.h/queue.h | 栈、循环队列接口 | 少 |
include/btree.h | 二叉搜索树接口 | 少 |
include/store.h | 文件读写接口 | 中 |
src/list.c | 单链表实现 | 中 |
src/sort.c | 冒泡、快排、归并及比较次数统计 | 多 |
src/menu.c | 菜单打印与输入解析 | 多 |
src/main.c | 初始化、主循环、退出清理 | 少 |
把menu.c单独拎出来是有讲究的。课设里最容易变的就是交互——老师今天要求加个“按姓名模糊查询”,明天要求导出 CSV,变的全是输入输出。只要菜单层只调用list_find_by_id这类接口,不去直接摸Node->next,改交互就不会碰坏数据结构。
2.2 头文件约定:把“能改的地方”和“不该动的地方”分开
头文件是这份代码里最值得较真的地方。很多课设的头文件只写了个结构体定义,函数全在.c里各自声明,编译能过,链接全靠运气。规范一点的做法是:头文件里只放类型、宏和函数原型,并且用extern "C"之外的那套#ifndef守卫挡住重复包含。
#ifndef LIST_H #define LIST_H /* 业务记录:课设里通常是一条学生或联系人信息 */ typedef struct { int id; /* 主键,全表唯一,链表和二叉树都靠它对齐 */ char name[32]; char phone[16]; float score; } Item; /* 节点结构体放在头文件里是为了让遍历回调能用, 但业务代码不应该直接改 next 指针 */ typedef struct Node { Item data; struct Node *next; } Node; /* 句柄:把 head 和 len 包在一起,函数签名就只需要一个参数 */ typedef struct { Node *head; /* 哨兵头节点,本身不存业务数据 */ int len; } List; List *list_create(void); void list_destroy(List *l); int list_insert(List *l, int pos, const Item *it); /* pos 从 0 开始,可等于 len */ int list_remove(List *l, int pos, Item *out); /* out 可为 NULL */ Node *list_find_by_id(List *l, int id); void list_traverse(List *l, void (*visit)(const Item *)); #endif /* LIST_H */这里有几个刻意的决定。返回值统一用int,0成功、负数失败,比void强太多——调用方至少能判断“是位置越界还是内存不够”。list_remove的out参数允许传NULL,这样只想删不要数据的场景不用造临时变量。list_traverse收一个函数指针,打印、写文件、统计平均分都能复用同一套遍历,不用为每种用途抄一遍while循环。
那段遍历接口的用法大致是这样:
static void print_item(const Item *it) { printf("%-6d %-12s %-14s %.1f\n", it->id, it->name, it->phone, it->score); } /* 调用处只关心"遍历做什么",不关心怎么走 */ list_traverse(list, print_item);visit收的是const Item *,而不是Node *,这是一条硬边界:回调函数拿不到next,就没办法在遍历中途把链表改坏。课设代码最常见的崩溃之一,就是在遍历回调里顺手删了当前节点,然后p = p->next读了一块已经释放的内存。
2.3 Makefile 与编译运行:三行命令把十几个 .c 串起来
多文件工程手敲gcc main.c list.c stack.c ...迟早会漏文件,尤其是加了新的.c之后忘了改命令,报一堆“undefined reference”。写个 Makefile,用wildcard自动收集源文件,加文件不用改构建脚本。
CC = gcc CFLAGS = -std=c11 -Wall -Wextra -g -fsanitize=address SRCS = $(wildcard src/*.c) OBJS = $(SRCS:.c=.o) TARGET = ds_demo $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $^ %.o: %.c $(CC) $(CFLAGS) -Iinclude -c $< -o $@ run: $(TARGET) ./$(TARGET) clean: rm -f $(OBJS) $(TARGET)-Iinclude让#include "list.h"不用写相对路径;-Wall -Wextra把隐式声明、未使用变量、有符号无符号比较这些坑提前暴露出来;-g是为了用 gdb 看栈;-fsanitize=address是这份课设里性价比最高的一个开关,越界写和释放后使用都会被它当场抓住并打印调用栈,比对着printf猜半天强得多。Windows 上用 MinGW 的话,-fsanitize=address支持得不太完整,可以先去掉,改用-fstack-protector-all。
跑起来就三步:
make # 编译,第一次会生成所有 .o make run # 运行交互菜单 make clean # 交作业前清掉中间文件提示:交课设前务必用
make clean && make完整重编一次。增量编译会掩盖头文件依赖缺失的问题,答辩现场换台机器全量编译直接报错,这种翻车每年都有。
3. 核心结构拆解:单链表、栈、二叉树各自的实现重心
课本上的算法伪代码和能跑的 C 代码之间,隔着一层“边界条件怎么处理”。数组越界、空指针、插入位置等于长度、删除最后一个节点,这些在伪代码里一句“略”带过的东西,才是课设真正要写对的部分。
3.1 带头节点的单链表:插入删除为什么不需要二级指针
不带头节点的链表,插入和删除第一个元素时要改head本身,函数得收Node **,写起来满屏(*p)->next,初学者在这里翻车是常态。加一个哨兵头节点,所有位置的操作就统一了:第一个元素也有前驱,前驱就是哨兵。
/* 在第 pos 个位置前插入,pos 从 0 开始,pos == len 表示尾插 */ int list_insert(List *l, int pos, const Item *it) { if (l == NULL || it == NULL || pos < 0 || pos > l->len) return -1; /* 参数校验放在最前面,不做半截修改 */ Node *prev = l->head; /* 从哨兵出发,走 pos 步到前驱 */ for (int i = 0; i < pos; ++i) prev = prev->next; Node *node = (Node *)malloc(sizeof(Node)); if (node == NULL) return -2; /* 和"位置非法"分开,方便调用方区分处理 */ node->data = *it; node->next = prev->next; /* 先接后,再断前 */ prev->next = node; l->len++; return 0; }指针赋值那两行的顺序不能反。如果先写prev->next = node,再写node->next = prev->next,此时prev->next已经指向node自己了,结果就是node->next = node,链表在插入点自成一个环,遍历时死循环。这个环还特别隐蔽——插入本身不报错,只有后面遍历或者保存文件时才卡死,属于典型的“症状离病灶很远”。
删除同理,有了哨兵就不用特判头节点:
int list_remove(List *l, int pos, Item *out) { if (l == NULL || pos < 0 || pos >= l->len) return -1; Node *prev = l->head; for (int i = 0; i < pos; ++i) prev = prev->next; Node *victim = prev->next; prev->next = victim->next; /* 先把链断开 */ if (out != NULL) *out = victim->data; /* 数据先拷出来,再释放内存 */ free(victim); l->len--; return 0; }*out = victim->data必须在free之前。顺序颠倒的话,拷贝的是已释放内存里的内容,本机上可能“碰巧”还是原值,换台机器或者开了 ASan 立刻现原形。这种不确定性的 bug 最难查,因为它的表现依赖堆的分配历史。
按 id 查找没什么花样,但要注意返回的是节点指针还是数据副本。返回Node *速度快,可调用方一旦拿它去改数据,就绕过了所有校验;课设规模小,两种都行,我一般返回指针,同时在注释里写清楚“只读”。
3.2 栈与循环队列:括号匹配和层次遍历的两个小应用
栈在课设里最常见的两个用途是括号匹配和表达式求值。括号匹配的实现短得可以背下来:遇左括号压栈,遇右括号弹栈比对,最后栈空才算配对成功。
/* 顺序栈,容量固定,课设规模够用 */ #define STACK_CAP 128 typedef struct { char data[STACK_CAP]; int top; /* 指向栈顶元素的下一个位置,空栈时为 0 */ } CharStack; int stack_push(CharStack *s, char c) { if (s->top >= STACK_CAP) return -1; /* 栈满 */ s->data[s->top++] = c; return 0; } int stack_pop(CharStack *s, char *out) { if (s->top <= 0) return -1; /* 空栈,别返回 data[-1] */ *out = s->data[--s->top]; return 0; } int bracket_match(const char *expr) { CharStack s = { .top = 0 }; for (const char *p = expr; *p; ++p) { if (*p == '(' || *p == '[' || *p == '{') { if (stack_push(&s, *p) != 0) return -1; } else if (*p == ')' || *p == ']' || *p == '}') { char top; if (stack_pop(&s, &top) != 0) return 0; /* 右括号多了 */ if ((*p == ')' && top != '(') || (*p == ']' && top != '[') || (*p == '}' && top != '{')) return 0; /* 类型不匹配 */ } } return s.top == 0; /* 左括号多了也会让栈非空 */ }队列这边,课设里用得最多的是二叉树层次遍历。顺序队列最怕“假溢出”——rear走到数组末尾但前面还空着一大片。教科书的解法是取模成循环队列,再空出一个槽位区分队空和队满。我更倾向于加一个count字段,逻辑直白,也不用牺牲一个存储单元。
#define QCAP 128 typedef struct { Item buf[QCAP]; int front; /* 队头下标 */ int rear; /* 队尾下标,指向下一个空位 */ int count; /* 元素个数,用它区分空和满 */ } Queue; int q_push(Queue *q, const Item *it) { if (q->count == QCAP) return -1; q->buf[q->rear] = *it; q->rear = (q->rear + 1) % QCAP; /* 取模回绕 */ q->count++; return 0; } int q_pop(Queue *q, Item *out) { if (q->count == 0) return -1; *out = q->buf[q->front]; q->front = (q->front + 1) % QCAP; q->count--; return 0; }rear = (rear + 1) % QCAP这一句是循环队列的全部精髓。很多同学写成rear++之后忘了取模,跑小数据完全正常,一到第 128 条记录就崩。判断条件写成count == QCAP而不是rear + 1 == front,读代码的人不用在脑子里画环形图。
3.3 二叉搜索树:插入、查找与删除的三种情况
课设里的二叉树大多以 id 为键做二叉搜索树,用来支持按学号快速定位。插入用递归最省事,关键是让递归函数返回新根,靠返回值去改父节点的孩子指针。
typedef struct TNode { Item data; struct TNode *left, *right; } TNode; TNode *bst_insert(TNode *root, const Item *it) { if (root == NULL) { TNode *n = (TNode *)calloc(1, sizeof(TNode)); if (n != NULL) n->data = *it; return n; /* 返回新节点给上一层挂 */ } if (it->id < root->data.id) root->left = bst_insert(root->left, it); else if (it->id > root->data.id) root->right = bst_insert(root->right, it); /* id 相等视为重复,直接丢弃;课设里这样处理比报错更省事 */ return root; }这里用calloc而不是malloc,左右孩子自动清零,省掉两行赋值,也避免了忘初始化导致的野指针。bst_insert不减root的所有权,也不做局部替换,所以递归调用直接把返回值赋回left/right是安全的。
删除才是二叉搜索树真正的难点,分三种情况:叶子直接删;只有一个孩子用孩子顶替;有两个孩子找右子树最小节点(中序后继)替换值,再递归删掉那个后继。
TNode *bst_remove(TNode *root, int id) { if (root == NULL) return NULL; if (id < root->data.id) { root->left = bst_remove(root->left, id); } else if (id > root->data.id) { root->right = bst_remove(root->right, id); } else { /* 情况一、二:至多一个孩子,直接用孩子接替 */ if (root->left == NULL) { TNode *r = root->right; free(root); return r; } if (root->right == NULL) { TNode *l = root->left; free(root); return l; } /* 情况三:两个孩子,找右子树最小节点,拷值后删除它 */ TNode *min = root->right; while (min->left != NULL) min = min->left; root->data = min->data; root->right = bst_remove(root->right, min->data.id); } return root; }两个孩子的处理有个容易忽略的点:替换的是data,不是节点本身。把min的整块数据拷到root,再去右子树里删掉那个 id,树的形状不用动,代码也短。
3.4 排序与查找:把比较次数打印出来才有说服力
课设要求“比较几种排序算法效率”时,光打印运行时间没有意义——几百条数据,计时精度根本不够,跑出来全是 0 毫秒。正确做法是统计比较次数和移动次数,这两个数字和算法复杂度直接对应,写进报告也站得住。
static long g_cmp = 0; /* 演示用全局计数,正式代码建议放进上下文结构体传参 */ static int cmp_int(int a, int b) { g_cmp++; return (a > b) - (a < b); /* 统一成 -1 / 0 / 1,避免返回 a-b 溢出 */ } void quick_sort(int *a, int lo, int hi) { if (lo >= hi) return; int pivot = a[(lo + hi) / 2]; int i = lo, j = hi; while (i <= j) { while (cmp_int(a[i], pivot) < 0) i++; while (cmp_int(a[j], pivot) > 0) j--; if (i <= j) { int t = a[i]; a[i] = a[j]; a[j] = t; i++; j--; } } quick_sort(a, lo, j); quick_sort(a, i, hi); }(a > b) - (a < b)这个写法值得记一下。直接return a - b在比较两个大整数时会整型溢出,排序结果就变成玄学——某些数据对顺序对,某些不对,查起来让人怀疑人生。比较函数返回 -1/0/1 在任何输入下都安全。
用同一份随机数据跑冒泡、快排、归并,把g_cmp打印出来,量级差异一目了然:一万条数据下冒泡是千万级比较,快排是十几万级。这比在报告里抄一段“快排平均复杂度 O(nlogn)”有说服力得多。
4. 持久化与内存:fwrite 一行写崩整个程序的血泪经验
课设不加文件存储,重启程序数据就没了,演示效果大打折扣。加了文件存储又引入一批新问题:格式怎么定、读回来怎么校验、异常文件怎么处理。这块写扎实,整个程序才算闭环。
4.1 落盘格式设计:为什么不要直接把结构体数组 fwrite 出去
网上流传最广的写法是fwrite(list, sizeof(Item), n, fp),把整块内存拍进文件。只要Item里全是定长字段(int、char数组、float),这么写确实能读回来。但一旦有人往Item里加了个char *name,写进去的就是指针值本身,下次运行时那个地址早就没意义了,读回来一printf就段错误。
我的做法是加一个文件头,存魔数、版本号和记录数,后面跟定长记录。
#include <stdint.h> /* 文件头:先写长度再写内容,读的时候才知道要读多少 */ typedef struct { uint32_t magic; /* 0x44534453 即 "DSDS",用来判断这文件是不是自己写的 */ uint32_t version; /* 格式版本,字段一变就加一,方便做兼容 */ uint32_t count; /* 记录条数 */ uint32_t reserved; /* 对齐占位,以后扩展字段不用改前面的偏移 */ } FileHeader; int list_save(const List *l, const char *path) { FILE *fp = fopen(path, "wb"); if (fp == NULL) return -1; FileHeader h = { 0x44534453u, 1u, (uint32_t)l->len, 0u }; if (fwrite(&h, sizeof h, 1, fp) != 1) { fclose(fp); return -1; } for (const Node *p = l->head->next; p != NULL; p = p->next) { /* Item 全部是定长字段、内部没有指针,整块写是安全的 */ if (fwrite(&p->data, sizeof(Item), 1, fp) != 1) { fclose(fp); return -1; } } fclose(fp); return 0; }魔数是为了防止用户选错文件。读一个 jpg 进来,前四个字节对不上,直接报“文件格式不正确”,而不是把图片内容当学生记录打印一屏乱码。版本号是为了以后改字段时不至于把旧文件读崩——读到version != 1就提示“文件版本过新,请升级程序”。
读取侧同样要防:
int list_load(List *l, const char *path) { FILE *fp = fopen(path, "rb"); if (fp == NULL) return -1; /* 文件不存在,交给调用方决定是否提示 */ FileHeader h; if (fread(&h, sizeof h, 1, fp) != 1) { fclose(fp); return -1; } if (h.magic != 0x44534453u) { fclose(fp); return -2; } /* 不是本程序的格式 */ if (h.version != 1u) { fclose(fp); return -3; } /* 版本不认识 */ if (h.count > 1000000u) { fclose(fp); return -4; } /* 条数离谱,多半是文件损坏 */ Item it; for (uint32_t i = 0; i < h.count; ++i) { if (fread(&it, sizeof it, 1, fp) != 1) break; /* 文件被截断,别硬读 */ list_insert(l, l->len, &it); } fclose(fp); return 0; }h.count那条上限校验是我吃过大亏才加的。文件头里的条数被写坏过一次(程序崩溃时正在写),变成了四十多亿,循环疯狂fread到文件尾后返回 0,但代码没判返回值,it保持上一次的旧值被反复插入,内存一路涨到被系统杀掉。加个上限判断,两行代码省掉一场灾难。
4.2 加载与释放:destroy 写不对,跑一百次漏一百次
课设规模不大,内存泄漏几十 KB 看不出来,老师也不会拿 Valgrind 查。但释放逻辑写错,除了泄漏还可能留下悬空指针,后续操作直接崩。
void list_destroy(List *l) { if (l == NULL) return; Node *p = l->head; while (p != NULL) { Node *next = p->next; /* 先把 next 存下来,再 free 当前节点 */ free(p); p = next; /* 顺序反了就是标准 UAF */ } free(l); /* 句柄本身也是 malloc 出来的,别忘了 */ }next那一行是整个函数的命门。写成free(p); p = p->next;编译器不会报错,小数据量下甚至能正常跑完——因为释放掉的内存块头部还没被复写,p->next读到的仍是原值。数据一多,free之后堆管理器可能立刻重排,p->next读出来就是垃圾地址,下一轮循环free一个非法指针,进程直接 abort。
二叉树这边用后序遍历释放:
void bst_destroy(TNode *root) { if (root == NULL) return; bst_destroy(root->left); /* 先递归释放左右子树 */ bst_destroy(root->right); free(root); /* 最后释放自己 */ }递归深度受树高限制。课设数据量小、随机插入,树高通常在 log n 量级,没问题。但如果测试数据的 id 是递增输入,二叉搜索树会退化成一条链,一万条数据就是一万层递归——默认 8MB 栈空间,每帧几十字节,勉强撑住;十万条就栈溢出了。这也是为什么后面的改造里值得提一句平衡树。
注意:退出程序前记得按“树 → 队列 → 链表”的顺序清理。先销毁链表再销毁引用链表的队列,队列里残留的指针就是悬空的。
5. 避坑与排查:课设代码最容易翻车的五个地方
调试课设代码有个规律:报错信息离真正的原因往往隔了好几层。下面这几条是反复出现、且新手几乎一定会撞上的。
5.1 输入与缓冲区相关
现象一:菜单选完功能,程序直接跳过了输入姓名的步骤。
原因几乎都是scanf("%d", &choice)之后接fgets(buf, sizeof buf, stdin)。scanf读走数字但把回车留在了缓冲区,fgets一进去就碰到\n,立刻返回一个空串。菜单看上去像“闪了一下”就跳到下一步。
解决办法是统一输入通道:要么全用fgets读一行再sscanf解析,要么在scanf后手动吃掉一行。我一般封装一个输入函数,把这两种混用彻底断掉。
/* 读一行到 buf,去掉末尾换行;返回 0 表示成功读到非空内容 */ #include <string.h> #include <stdio.h> int read_line(char *buf, int size) { if (fgets(buf, size, stdin) == NULL) return -1; size_t n = strlen(buf); if (n > 0 && buf[n - 1] == '\n') { buf[n - 1] = '\0'; /* 去掉换行 */ } else { /* 这一行太长,缓冲区里还剩脏数据,清掉 */ int c; while ((c = getchar()) != '\n' && c != EOF) { } } return buf[0] == '\0' ? -1 : 0; }那个else分支很多人不写。输入超过size个字符时,fgets只读走一部分,剩下的留在缓冲区里,会被下一次输入当成输入内容,症状是“输入一遍名字,后面几步全自动执行完了”。
现象二:按姓名查询永远查不到,明明刚存进去。
原因是if (node->data.name > key)这种写法。数组名退化成指针,比的是地址大小,不是内容,结果要么恒真要么恒假。正确写法是strcmp(node->data.name, key) == 0,记得#include <string.h>。这个坑在字符串和数字混用的结构体里特别容易出现,编译器开-Wall也只给个警告。
5.2 指针与内存相关
现象三:删除操作执行完程序没报错,但再遍历一次就卡死或乱码。
原因在 3.1 里提过——插入时指针赋值顺序写反造出了环,或者删除时忘了prev->next = victim->next直接把节点free了。链表被切断,遍历走到free掉的内存上。
排查手法很土但有效:在遍历循环里加一个步数计数,超过len就打印“疑似成环”并退出。
void list_traverse(List *l, void (*visit)(const Item *)) { int guard = 0; for (Node *p = l->head->next; p != NULL; p = p->next) { if (++guard > l->len) { /* 走的步数超过记录数,必然有环 */ fprintf(stderr, "[fatal] 链表疑似成环,pos=%d\n", guard); break; } visit(&p->data); } }现象四:程序跑着跑着突然终止,报free(): invalid pointer或double free。
多半是同一个指针被释放了两次,或者free的不是malloc返回的原地址(比如free(p + 1)、free(&array[0]))。课设里最典型的是在菜单循环里,用户选“清空”调了一次list_destroy,之后没把句柄置NULL,退出时又调了一次。
解决办法是在list_destroy里把结构体字段清空,并且调用处销毁后立刻把指针置NULL。函数参数是值传递,置NULL必须在调用方做,或者接口改成void list_destroy(List **pl)。
5.3 编译与链接相关
现象五:单文件编译好好的,改成多文件后满屏multiple definition of 'xxx'。
原因是在头文件里直接写了变量定义,比如int g_count = 0;,几个.c都#include它,每个编译单元都生成一份强符号,链接器直接冲突。正确做法是在头文件里写extern int g_count;,在某个.c里写一次定义。
/* include/global.h */ #ifndef GLOBAL_H #define GLOBAL_H extern int g_count; /* 声明,不分配存储 */ #endif /* src/global.c */ #include "global.h" int g_count = 0; /* 全工程仅此一处定义 */还有个更隐蔽的变体:头文件里写static int g_count = 0;。这样能编译能链接,但每个.c都有自己独立的一份拷贝,list.c里加了一,menu.c里读到的还是 0。症状是“计数器永远是零”,查半天查不出错。看到static变量在头文件里,基本可以直接判定有问题。
提示:编译时把警告当错误看,
-Wall -Wextra -Werror一起开,能拦掉大部分这类问题。头两天会觉得烦,后面省下的调试时间远超这点折腾。
6. 二次开发:从菜单驱动改成命令驱动,再加一层可替换的存储后端
课设交完之后,这套代码其实还有不少可挖的价值。最直接的一个改造方向,是把“菜单 +scanf”换成“命令行参数 + 子命令”。改动集中在menu.c和main.c,数据结构层一行不用动——这正是当初分层带来的好处。
具体做法是搞一张命令分发表,每个子命令一个处理函数,签名统一。
#include <string.h> #include <stdio.h> typedef struct { const char *name; int (*handler)(int argc, char **argv); /* argc/argv 已经剥掉程序名和子命令 */ } Command; static int cmd_add(int argc, char **argv) { /* 解析参数后调 list_insert */ return 0; } static int cmd_list(int argc, char **argv) { /* 遍历打印 */ return 0; } static int cmd_del(int argc, char **argv) { /* 按 id 删除 */ return 0; } static int cmd_stat(int argc, char **argv) { /* 输出条数、平均分 */ return 0; } static const Command g_cmds[] = { { "add", cmd_add }, { "list", cmd_list }, { "del", cmd_del }, { "stat", cmd_stat }, { NULL, NULL }, /* 哨兵,省掉一个 count 变量 */ }; int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "usage: ds_demo <add|list|del|stat> [args]\n"); return 2; } for (int i = 0; g_cmds[i].name != NULL; ++i) { if (strcmp(g_cmds[i].name, argv[1]) == 0) return g_cmds[i].handler(argc - 2, argv + 2); } fprintf(stderr, "unknown command: %s\n", argv[1]); return 2; }改成这种方式之后,./ds_demo add --id 1001 --name alice和./ds_demo stat就能直接跑,配合 shell 脚本可以做批量导入,也方便写自动测试——这是菜单驱动做不到的。分发用数组加NULL哨兵,比if-else链短,加命令只往表里塞一行。
第二个改造点是存储后端。现在store.c写死了二进制格式,想换 CSV 就得改list_save。抽一层接口出来,把“序列化”和“存哪里”分开:
| 改造点 | 改动范围 | 大致工作量 | 收益 |
|---|---|---|---|
| 菜单改命令分发 | main.c、menu.c | 半天 | 可脚本化、可自动化测试 |
| 存储后端抽象 | 新增backend.h,store.c拆两份 | 一天 | 二进制/CSV/内存三种可切 |
| 换平衡树 | btree.c | 一天半 | 顺序输入不再退化成链 |
| 加单元测试 | 新增tests/ | 一天 | 改完敢合,不怕改坏 |
存储接口大致长这样:
/* 后端接口:init / load / save / close 四个动作,实现可以随便换 */ typedef struct { int (*init)(void *ctx, const char *path); int (*load)(void *ctx, List *out); int (*save)(void *ctx, const List *in); void (*close)(void *ctx); void *ctx; /* 文件句柄、缓冲区之类,由具体实现自己管 */ } StoreBackend;ctx这个字段是关键。有了它,二进制后端可以往里面放FILE *,内存后端可以放一个List *,中间层完全不用知道具体是什么。这种写法在课设里算超纲,但你把它写进报告里,比多写三个排序算法更能体现对抽象的理解。
验证改造有没有改坏,我的习惯是三步:先make clean && make全量重编,看警告;再跑-fsanitize=address版本,把增删改查各走一遍,尤其是删除后再保存、保存后再加载这一串;最后拿同一份数据同时跑旧版和新版,比对导出的 CSV 是否逐行一致。第三步是最有效的后悔药——很多时候程序不崩、结果不对,只有做等价性比对才能发现。
这套流程是被坑出来的。有一次我把list_load里的插入位置从l->len改成了0,程序一切正常,只是数据顺序反了,直到导出的 CSV 和原文件对不上才发现。从那以后我每次动持久化代码,都要强制走一遍“保存 → 重新加载 → 全量导出 → 与基线 diff”,不管改动多小。希望帮到你。
本文还有配套的精品资源,点击获取