数据结构课程设计C语言实现:从动态数组到哈希表的工程训练
发布时间:2026/10/10 6:33:27 锦皓数字建站

简介这是一份面向计算机专业学生与C语言学习者的数据结构课程设计完整源码包围绕单链表、栈、队列、二叉树与图五种核心结构展开通过多级菜单串联各模块的基本操作与典型应用适合课程设计参考、期末复习或自学练手。压缩包共28个文件约500KB以h头文件与cpp源文件为主分别承载各结构的接口声明与实现另含sln、vcxproj等工程配置及exe可执行文件便于直接编译运行与调试。资源涵盖一元多项式运算、通讯录、表达式求值、酒店客房分配、二叉排序树、Huffman编码、拓扑排序与关键路径等应用场景读者可据此理解结构选型与算法落地思路并对照头文件划分梳理模块化设计方法。目前已有1709人学习下载适合需要完整课设方案与排错参考的读者。1. 数据结构课程设计C语言实现为什么它是从“会写代码”到“能扛项目”的分水岭很多人对数据结构课程设计C语言实现的印象停留在“写个链表、排个序就交差”。但真正做过一轮完整设计的人会发现它考的不是语法而是你能否把内存布局、指针操作、时间复杂度和边界条件同时管住。我带过几届课程设计的复盘最扎心的结论是能独立用C把哈希表和平衡树写对、写稳、写可测的人后面接任何嵌入式、后端或系统方向的项目上手速度至少快一倍。这个标题对应的不是一份作业而是一条最小可用的工程训练路径用C语言把线性表、栈队列、树、图、查找与排序逐个实现再拼成一个可交互的演示系统。适合刚学完C语法、想摆脱“只会刷题”的在校生也适合工作后想补底层功底的开发者。下面按“选型—实现—排错—进阶”的顺序把这条路走一遍。2. 先定架构再写代码课程设计的模块划分与数据结构选型2.1 为什么先画模块图再动手能省掉一半返工课程设计最常见的翻车方式是打开编辑器就从main函数往下写写到一半发现“学生信息”既要用链表存又要按学号查于是临时加数组最后两套数据不同步。我的习惯是先花二十分钟做三件事列实体、定操作、选结构。实体就是系统里真正要管理的东西比如学生、课程、成绩、图书、站点。操作分四类增、删、改、查再加一类“批量统计”。结构选型看操作频率查多增少用有序数组加二分增删频繁用链表需要按键快速定位用哈希需要有序遍历用二叉搜索树或平衡树。以“学生成绩管理”为例实体是学生主键是学号操作里查询和插入都多还要按成绩排名。这时候单链表查询是O(n)哈希查询平均O(1)但不支持范围排名所以常见做法是哈希表做主索引、链表做插入顺序、再加一个按成绩排序的索引数组。课程设计不要求你做到数据库级别但要求你说得清“为什么选它”。提示选型理由写进设计文档答辩时老师问的第一句往往就是“你为什么不用XX结构”提前准备好对比。2.2 用一张表把数据结构和操作复杂度对齐下面这张表是我一般会让学生先填的填完再写代码返工率明显下降。模块主结构关键操作平均复杂度选它的理由线性表动态数组按下标读写O(1)缓存友好扩容摊还O(1)栈数组/链式入栈出栈O(1)括号匹配、表达式求值队列循环数组入队出队O(1)层次遍历、任务调度二叉搜索树链式节点插入查找删除O(log n)~O(n)有序、易实现退化需警惕哈希表数组链插入查找O(1)平均主键快速定位图邻接表遍历、最短路O(VE)稀疏图省内存填表时有个细节容易被忽略复杂度写的是平均还是最坏。二叉搜索树在有序插入时会退化成链表最坏O(n)这就是后面要写平衡或随机化的原因。哈希表最坏也是O(n)取决于冲突处理。把这两列写清楚设计文档的深度就出来了。2.3 目录结构和编译方式先固定下来课程设计代码量通常在两千到五千行全塞一个.c文件后期没法维护。我一般按模块拆文件头文件放声明源文件放实现再加一个main.c做菜单驱动。# 推荐的目录结构 ds_course_design/ ├── include/ # 头文件 │ ├── list.h │ ├── stack.h │ ├── queue.h │ ├── bst.h │ ├── hash.h │ └── graph.h ├── src/ # 源文件 │ ├── list.c │ ├── stack.c │ ├── queue.c │ ├── bst.c │ ├── hash.c │ ├── graph.c │ └── main.c ├── tests/ # 单元测试 │ └── test_list.c ├── Makefile └── README.md编译用Makefile统一管理避免每次手敲一长串gcc命令。下面是一个最小可用的Makefile带-Wall -Wextra -g把警告当错误看能提前暴露大量指针问题。CC gcc CFLAGS -Wall -Wextra -g -Iinclude SRCS $(wildcard src/*.c) OBJS $(SRCS:.c.o) TARGET ds_app $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) $(TARGET) .PHONY: clean逻辑说明wildcard自动收集src下所有源文件新增模块不用改Makefile-Iinclude让编译器找到头文件-g保留调试符号方便用gdb定位段错误。参数上如果项目要提交且不允许警告把-Wall -Wextra换成-Wall -Wextra -Werror强制清零警告。注意%.o: %.c这条规则依赖头文件变化时不会自动重编课程设计规模小可以接受想严谨就加-MMD -MP生成依赖文件。3. 核心结构的C语言落地从动态数组到哈希表的可复现实现3.1 动态数组扩容策略和内存泄漏的两个关键点动态数组是后面很多结构的基础先把它写对。核心是容量、长度、扩容倍数三个量。// include/list.h #ifndef LIST_H #define LIST_H typedef struct { int *data; // 元素数组 int size; // 当前元素个数 int capacity; // 当前容量 } DynArray; DynArray *da_create(int init_cap); void da_destroy(DynArray *da); int da_push(DynArray *da, int value); int da_get(DynArray *da, int index, int *out); int da_remove(DynArray *da, int index); #endif// src/list.c #include list.h #include stdlib.h #include string.h DynArray *da_create(int init_cap) { if (init_cap 0) init_cap 4; DynArray *da (DynArray *)malloc(sizeof(DynArray)); if (!da) return NULL; da-data (int *)malloc(sizeof(int) * init_cap); if (!da-data) { free(da); return NULL; } da-size 0; da-capacity init_cap; return da; } static int da_reserve(DynArray *da, int need) { if (need da-capacity) return 1; int new_cap da-capacity; while (new_cap need) new_cap * 2; // 成倍扩容摊还O(1) int *p (int *)realloc(da-data, sizeof(int) * new_cap); if (!p) return 0; da-data p; da-capacity new_cap; return 1; } int da_push(DynArray *da, int value) { if (!da_reserve(da, da-size 1)) return 0; da-data[da-size] value; return 1; } int da_get(DynArray *da, int index, int *out) { if (index 0 || index da-size) return 0; *out da-data[index]; return 1; } int da_remove(DynArray *da, int index) { if (index 0 || index da-size) return 0; memmove(da-data[index], da-data[index 1], sizeof(int) * (da-size - index - 1)); da-size--; return 1; } void da_destroy(DynArray *da) { if (!da) return; free(da-data); free(da); }逻辑说明da_reserve用成倍扩容保证连续push的摊还复杂度是O(1)realloc失败时原指针仍有效所以先用临时指针p接收成功后再赋值避免内存泄漏。da_remove用memmove而不是循环因为源和目标重叠memcpy在重叠时行为未定义这是血泪经验。参数上init_cap给4是折中太小频繁扩容太大浪费内存如果元素类型不是int把int换成void *或具体结构体同时注意深拷贝问题。3.2 栈与队列用同一套接口风格降低心智负担栈和队列的接口风格统一成create/push/pop/destroy后面写表达式求值和层次遍历时不用反复查函数名。// include/stack.h #ifndef STACK_H #define STACK_H typedef struct { int *data; int top; // 栈顶下标-1表示空 int capacity; } Stack; Stack *st_create(int cap); void st_destroy(Stack *s); int st_push(Stack *s, int v); int st_pop(Stack *s, int *out); int st_peek(const Stack *s, int *out); int st_empty(const Stack *s); #endif// src/stack.c #include stack.h #include stdlib.h Stack *st_create(int cap) { if (cap 0) cap 16; Stack *s (Stack *)malloc(sizeof(Stack)); if (!s) return NULL; s-data (int *)malloc(sizeof(int) * cap); if (!s-data) { free(s); return NULL; } s-top -1; s-capacity cap; return s; } int st_push(Stack *s, int v) { if (s-top 1 s-capacity) return 0; // 课程设计可固定容量 s-data[s-top] v; return 1; } int st_pop(Stack *s, int *out) { if (s-top 0) return 0; *out s-data[s-top--]; return 1; } int st_peek(const Stack *s, int *out) { if (s-top 0) return 0; *out s-data[s-top]; return 1; } int st_empty(const Stack *s) { return s-top 0; } void st_destroy(Stack *s) { if (!s) return; free(s-data); free(s); }逻辑说明栈用固定容量因为课程设计里栈的深度可预估比如表达式长度如果要做通用容器把st_push改成先扩容再入栈。st_pop和st_peek都通过出参返回返回值表示成功与否这样调用方必须处理空栈避免读到脏数据。队列用循环数组实现关键是front、rear、count三个量判断满和空都看count比留一个空位更直观。3.3 哈希表除留余数加链地址冲突处理要能说清哈希表是课程设计里最能体现“工程取舍”的结构。主键是学号或ID时用除留余数法选素数做表长冲突用链地址法。// include/hash.h #ifndef HASH_H #define HASH_H typedef struct HashNode { int key; int value; struct HashNode *next; } HashNode; typedef struct { HashNode **buckets; int bucket_count; int size; } HashMap; HashMap *hm_create(int bucket_count); void hm_destroy(HashMap *hm); int hm_put(HashMap *hm, int key, int value); int hm_get(HashMap *hm, int key, int *out); int hm_remove(HashMap *hm, int key); #endif// src/hash.c #include hash.h #include stdlib.h static int hm_hash(const HashMap *hm, int key) { int h key % hm-bucket_count; return h 0 ? h hm-bucket_count : h; // 处理负数key } HashMap *hm_create(int bucket_count) { if (bucket_count 0) bucket_count 97; // 默认素数 HashMap *hm (HashMap *)malloc(sizeof(HashMap)); if (!hm) return NULL; hm-buckets (HashNode **)calloc(bucket_count, sizeof(HashNode *)); if (!hm-buckets) { free(hm); return NULL; } hm-bucket_count bucket_count; hm-size 0; return hm; } int hm_put(HashMap *hm, int key, int value) { int idx hm_hash(hm, key); for (HashNode *p hm-buckets[idx]; p; p p-next) { if (p-key key) { p-value value; return 1; } // 更新 } HashNode *node (HashNode *)malloc(sizeof(HashNode)); if (!node) return 0; node-key key; node-value value; node-next hm-buckets[idx]; // 头插O(1) hm-buckets[idx] node; hm-size; return 1; } int hm_get(HashMap *hm, int key, int *out) { int idx hm_hash(hm, key); for (HashNode *p hm-buckets[idx]; p; p p-next) { if (p-key key) { *out p-value; return 1; } } return 0; } int hm_remove(HashMap *hm, int key) { int idx hm_hash(hm, key); HashNode **pp hm-buckets[idx]; while (*pp) { if ((*pp)-key key) { HashNode *victim *pp; *pp victim-next; free(victim); hm-size--; return 1; } pp (*pp)-next; } return 0; } void hm_destroy(HashMap *hm) { if (!hm) return; for (int i 0; i hm-bucket_count; i) { HashNode *p hm-buckets[i]; while (p) { HashNode *n p-next; free(p); p n; } } free(hm-buckets); free(hm); }逻辑说明hm_hash对负数取模做了修正C语言里-1 % 97是-1直接当索引用会越界这是新手最常踩的坑。hm_put先查再插保证同一个key只存一份更新时直接改value。hm_remove用二级指针pp指向“指向节点的指针”这样删除头节点和中间节点用同一套代码不用特判。参数上bucket_count取素数能减少聚集常见取97、193、389如果key分布均匀取2的幂配合位运算也可以但课程设计里素数更稳妥。3.4 二叉搜索树与图的邻接表递归和迭代的取舍二叉搜索树的插入和查找用递归写最简洁但删除有两个孩子的节点时要找到右子树最小节点替换这一步容易写错。// include/bst.h #ifndef BST_H #define BST_H typedef struct BSTNode { int key; struct BSTNode *left, *right; } BSTNode; BSTNode *bst_insert(BSTNode *root, int key); BSTNode *bst_find(BSTNode *root, int key); BSTNode *bst_delete(BSTNode *root, int key); void bst_inorder(BSTNode *root, void (*visit)(int)); void bst_destroy(BSTNode *root); #endif// src/bst.c #include bst.h #include stdlib.h BSTNode *bst_insert(BSTNode *root, int key) { if (!root) { BSTNode *n (BSTNode *)malloc(sizeof(BSTNode)); if (!n) return NULL; n-key key; n-left n-right NULL; return n; } if (key root-key) root-left bst_insert(root-left, key); else if (key root-key) root-right bst_insert(root-right, key); // 相等不插入保持集合语义 return root; } BSTNode *bst_find(BSTNode *root, int key) { while (root) { if (key root-key) return root; root (key root-key) ? root-left : root-right; } return NULL; } BSTNode *bst_delete(BSTNode *root, int key) { if (!root) return NULL; if (key root-key) root-left bst_delete(root-left, key); else if (key root-key) root-right bst_delete(root-right, key); else { if (!root-left) { BSTNode *r root-right; free(root); return r; } if (!root-right) { BSTNode *l root-left; free(root); return l; } // 两个孩子找右子树最小节点 BSTNode *min root-right; while (min-left) min min-left; root-key min-key; root-right bst_delete(root-right, min-key); } return root; } void bst_inorder(BSTNode *root, void (*visit)(int)) { if (!root) return; bst_inorder(root-left, visit); visit(root-key); bst_inorder(root-right, visit); } void bst_destroy(BSTNode *root) { if (!root) return; bst_destroy(root-left); bst_destroy(root-right); free(root); }逻辑说明bst_insert递归返回新子树根这样能处理空树和正常插入两种情况。bst_find改成迭代避免递归深度过大。bst_delete分三种情况两个孩子时用右子树最小节点替换再递归删除那个最小节点注意替换的是key不是节点指针这样不用改父节点指向。参数上如果数据是有序插入树会退化成链表课程设计里可以加随机化或改用AVL但AVL的旋转代码量翻倍答辩时能说清退化条件和应对思路即可。图的邻接表用数组加链表适合稀疏图。深度优先用递归广度优先用队列这两个遍历是后面最短路和拓扑排序的基础。// include/graph.h #ifndef GRAPH_H #define GRAPH_H typedef struct Edge { int to; int weight; struct Edge *next; } Edge; typedef struct { Edge **adj; // 邻接表头指针数组 int n; // 顶点数 } Graph; Graph *g_create(int n); void g_add_edge(Graph *g, int u, int v, int w); void g_dfs(Graph *g, int start, int *visited); void g_bfs(Graph *g, int start, int *visited); void g_destroy(Graph *g); #endif// src/graph.c #include graph.h #include stdlib.h #include string.h Graph *g_create(int n) { Graph *g (Graph *)malloc(sizeof(Graph)); if (!g) return NULL; g-adj (Edge **)calloc(n, sizeof(Edge *)); if (!g-adj) { free(g); return NULL; } g-n n; return g; } void g_add_edge(Graph *g, int u, int v, int w) { Edge *e (Edge *)malloc(sizeof(Edge)); if (!e) return; e-to v; e-weight w; e-next g-adj[u]; g-adj[u] e; } void g_dfs(Graph *g, int start, int *visited) { visited[start] 1; // 这里可以替换成具体业务比如打印顶点 for (Edge *e g-adj[start]; e; e e-next) { if (!visited[e-to]) g_dfs(g, e-to, visited); } } void g_bfs(Graph *g, int start, int *visited) { int *queue (int *)malloc(sizeof(int) * g-n); int front 0, rear 0; visited[start] 1; queue[rear] start; while (front rear) { int u queue[front]; for (Edge *e g-adj[u]; e; e e-next) { if (!visited[e-to]) { visited[e-to] 1; queue[rear] e-to; } } } free(queue); } void g_destroy(Graph *g) { if (!g) return; for (int i 0; i g-n; i) { Edge *e g-adj[i]; while (e) { Edge *n e-next; free(e); e n; } } free(g-adj); free(g); }逻辑说明g_add_edge用头插添加O(1)遍历顺序与插入顺序相反课程设计里通常无所谓。g_dfs递归实现注意visited数组由调用方分配并清零避免每次遍历重新分配。g_bfs用数组模拟队列因为顶点数已知比链式队列更省事。参数上weight在无权图里可以忽略但保留字段方便后面扩展最短路如果图是有向的只加一条边无向图加两条。4. 避坑与排查课程设计里最容易翻车的五个地方4.1 段错误先看指针是否越界再看内存是否释放后使用现象程序运行到某个操作突然崩溃gdb显示SIGSEGV。原因通常是数组下标越界、访问空指针、或free之后继续用。解决编译加-g用gdb ./ds_app跑崩溃后敲bt看调用栈定位到具体行再用valgrind --leak-checkfull ./ds_app检查内存错误它会直接指出哪一行越界、哪一块内存泄漏。我一般会在每个模块的测试里先跑一遍valgrind比肉眼查快得多。4.2 内存泄漏每个malloc都要有对应的free路径现象程序跑久了内存占用持续上涨或者valgrind报告definitely lost。原因创建了结构体但销毁函数漏了某个字段或者中途return没走清理逻辑。解决给每个模块写destroy函数内部按“先子后父”的顺序释放在main里用atexit或统一出口调用。更稳的做法是写单元测试创建-使用-销毁循环一万次看内存是否稳定。4.3 哈希冲突导致性能骤降表长和哈希函数要一起调现象哈希表插入一万个元素后查找明显变慢。原因表长太小或哈希函数分布不均链表过长。解决把bucket_count调到元素数的1.3倍左右并取素数如果key本身有规律比如都是偶数先做一次混合比如key key * 2654435761u再取模。课程设计里可以打印每个桶的链表长度超过8就说明需要调。4.4 递归深度过大导致栈溢出树和图的遍历要留退路现象对一万个节点的有序二叉搜索树做中序遍历程序崩溃。原因树退化成链表递归深度等于节点数默认栈空间不够。解决把递归改成显式栈迭代或者插入时随机化打乱顺序。图的DFS同理顶点多时用迭代加栈。这个坑在答辩演示时特别容易暴露因为演示数据往往比测试数据大。4.5 输入输出格式问题菜单驱动系统的交互要能容错现象用户输入字母程序进入死循环。原因scanf读整数失败后非法字符留在缓冲区下次继续失败。解决读入后用getchar清缓冲或者统一用fgets读一行再sscanf解析。菜单里每个选项都要有默认分支提示“输入无效请重新选择”。这个细节不影响算法但直接影响演示效果。5. 从能跑到能讲课程设计的验证、演示与进阶技巧5.1 用单元测试把“我觉得对”变成“跑出来对”课程设计交上去之前至少给每个核心结构写一组测试。不需要引入复杂框架一个assert加一个计数就够。// tests/test_list.c #include list.h #include assert.h #include stdio.h int main(void) { DynArray *da da_create(2); assert(da ! NULL); for (int i 0; i 1000; i) { assert(da_push(da, i * 10) 1); } assert(da-size 1000); int v 0; assert(da_get(da, 500, v) 1 v 5000); assert(da_get(da, 1000, v) 0); // 越界必须失败 assert(da_remove(da, 0) 1); assert(da-size 999); assert(da_get(da, 0, v) 1 v 10); da_destroy(da); printf(test_list passed\n); return 0; }逻辑说明测试覆盖正常插入、边界读取、越界读取、删除后状态四类情况。assert在失败时直接终止并打印行号比打印日志更快定位。参数上循环一千次是为了触发多次扩容验证da_reserve的正确性如果项目用-DNDEBUG编译assert会被去掉所以测试要用单独的编译目标不加这个宏。5.2 演示系统的三条主线初始化、操作、统计答辩演示时间通常只有五到十分钟菜单要能快速展示核心功能。我一般把演示数据做成“一键加载”比如预置二十个学生、十条边然后按“查询-插入-删除-统计”的顺序走一遍。统计功能是加分项比如哈希表的平均查找长度、二叉搜索树的高度、图的最短路结果这些能直接体现你对复杂度的理解。5.3 进阶方向把课程设计变成简历上的一个点如果时间允许挑一个方向做深。比如给哈希表加动态扩容负载因子超过0.75就翻倍并重新哈希给二叉搜索树加AVL旋转保证最坏O(log n)给图加Dijkstra最短路用优先队列优化。这些改动不需要全做做一个并写清测试数据就能在面试里讲十分钟。我见过最实在的做法是把每个结构的操作次数和耗时打印出来用数据说明“为什么选它”。5.4 我自己的习惯先写销毁函数再写创建函数最后说一个我踩过多次坑之后养成的习惯每写一个create立刻把对应的destroy写完哪怕里面是空的。这样在写业务逻辑时心里始终有一条“资源从哪来、到哪去”的线内存泄漏和野指针会少很多。课程设计如此后面做任何C项目也一样。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。