简介:华中科技大学数据结构实验资源包,面向计算机学院学生及需要动手巩固数据结构基础的初学者,以C语言完整实现顺序表、单链表、二叉树和邻接表(无向图)四个经典实验,帮助解决实验代码编写与算法理解上的难点。压缩包总计4个文件,均为.c源文件,整体仅17KB,轻量精炼,便于直接查看和借鉴核心代码。目前已有574人学习下载,适合作为课程实验参考、期末复习或自学练习的配套材料。内容涵盖顺序表创建、插入、删除与查找,单链表头节点管理及插入、删除、遍历,二叉树递归创建与前序、中序、后序遍历,以及无向图邻接表的构建与深度优先搜索。代码结构清晰、注释直白,既便于对照课程理论完成实验,也有助于快速定位和修改代码问题,能有效提升对数据结构底层逻辑和C语言编程实践的掌握。
1. 数据结构实验:为什么挂科的人往往先栽在指针上
数据结构实验这门课,绝大多数人不是被图算法难倒的,而是倒在一件看起来特别小的事上:malloc 完了忘 free,链表插入时没找到前驱节点,或者递归层数一多直接栈溢出。这门实验课名义上考的是“对数据结构的理解”,实际上大部分时间在考“你的代码能不能在没人盯着的角落里自己跑稳”。本文要讲的,就是把这门课从“玄学”变成“可复现工程”的完整路径:怎么选环境、怎么组织代码、每个实验的核心算法怎么写、哪些坑是每一届都会有人踩的。适合正在做实验、调试调到头秃、或者准备补考的读者,也适合想从“抄代码”过渡到“自己写”的人。
2. 环境和工程组织:先解决“跑不起来”的问题
2.1 编译器与 IDE 选型:别在工具上内耗
数据结构实验的代码量一般不超过几千行,这时候工具的核心价值只有一个:调试器好不好用。能打断点、能看变量、能看内存,就够用了。
| 方案 | 适用场景 | 调试能力 | 注意事项 |
|---|---|---|---|
| Visual Studio / VS Code + MSVC | Windows 下想开箱即用 | 断点、监视、内存视图都很强 | 纯 C 工程要手动关掉“预编译头” |
| Code::Blocks + MinGW | 课程机房常见配置 | 断点可用 | 调试器路径经常没配,容易报错 |
| CLion + MinGW | 愿意折腾 IDE | 强,界面现代化 | 需要额外装 MinGW,第一次配置麻烦 |
| gcc + Makefile | Linux / 命令行 | gdb 有门槛 | 最通用,但学习成本高 |
我一般会建议用 Code::Blocks 或 VS,原因很直接:课程评分和答辩是在机房电脑上做的,机房装什么你就用什么,别搞特殊。如果你是在自己电脑上写,VS 的调试器对学生党最友好——尤其是“局部变量”和“调用堆栈”两个窗口,能帮你省下大量用 printf 猜错误的时间。
这里有一个隐藏但很重要的点:源码编码。Windows 控制台默认代码页是 GBK,如果你用 UTF-8 保存源码并打印中文,会出现乱码。最稳的解决办法是:源码文件用 GBK 保存,或者代码里只打印英文和数字。这事看起来小,却能让实验报告扣分,也经常让同学以为代码坏了。
2.2 多文件工程:头文件和源文件分离的最小模板
很多同学的实验代码全部塞在一个 main.c 里,这个做法不是不行,但一旦实验要求“链表、树、图各写一个模块”,单文件就会让代码越混越乱。正确的做法是头文件放声明,源文件放实现,main 只负责调用。下面是一个最精简的三文件模板,以单链表为例。
// list.h #ifndef LIST_H #define LIST_H typedef struct Node { int data; struct Node* next; } Node; Node* list_create(void); void list_insert(Node* head, int pos, int val); void list_free(Node* head); #endif// list.c #include <stdlib.h> #include "list.h" Node* list_create(void) { Node* head = (Node*)malloc(sizeof(Node)); if (!head) return NULL; // 内存分配失败要处理 head->next = NULL; return head; } void list_insert(Node* head, int pos, int val) { Node* pre = head; for (int i = 0; i < pos && pre->next; i++) { pre = pre->next; } Node* node = (Node*)malloc(sizeof(Node)); node->data = val; node->next = pre->next; pre->next = node; } void list_free(Node* head) { Node* cur = head; while (cur) { Node* tmp = cur->next; free(cur); cur = tmp; } }// main.c #include <stdio.h> #include "list.h" int main(void) { Node* head = list_create(); list_insert(head, 0, 10); list_insert(head, 1, 20); for (Node* p = head->next; p; p = p->next) { printf("%d ", p->data); } printf("\n"); list_free(head); return 0; }这里的逻辑说明很直接:list.h 只放结构体和函数声明,用#ifndef LIST_H防止头文件被重复包含;list.c 放具体实现,malloc 之后立刻判断返回值;main.c 只管调用和打印。注意list_insert里pre从头节点开始走,这样插入位置 0 时也能正确操作,不需要单独处理“插在头部”的特例。
参数说明:pos表示要插入的位置,0是第一个数据节点;pre->next为 NULL 时,pre停在最后一个节点,此时插入相当于尾插。这个设计把“空链表插入”和“尾部插入”统一成同一个逻辑,是链表实验里最值得抄的写法。
2.3 内存分配:每个 malloc 都要有对应的 free
数据结构实验里的运行时崩溃,一半以上和内存有关。C 语言不像 Java 有垃圾回收,malloc 出来的内存你不主动释放,程序退出后也会被系统回收,但实验中有一种情况会真正出问题:循环里不断 malloc 而不释放,程序跑一会内存耗尽直接崩。还有更隐蔽的:释放之后又访问,因为那块内存已经被别人用了。
我的习惯是写代码时就定一条规矩:malloc 和 free 成对出现。如果你在函数 A 里 malloc,要在函数 B 里 free,那就说明设计有问题,应该考虑把内存所有权收拢到一个函数里。链表、树、图这类结构,写一个xxx_free的递归或遍历函数,把整棵结构释放干净,而不是只放掉头节点——那是典型的血泪经验。
3. 六大类必做实验的核心实现与关键参数
3.1 顺序表与链表:线性表的两条路线
顺序表的实验题一般是“实现插入、删除、按值查找”,难点在扩容。初始容量开多大,扩容扩多少,是有讲究的。开太小频繁扩容浪费性能,开太大浪费空间。
typedef struct { int* data; int size; int capacity; } SeqList; int expand(SeqList* l) { if (l->size >= l->capacity) { int new_cap = l->capacity * 2; // 翻倍扩容 int* new_data = (int*)realloc(l->data, new_cap * sizeof(int)); if (!new_data) return -1; // realloc 失败要保留旧指针 l->data = new_data; l->capacity = new_cap; } return 0; }逻辑说明:realloc会尝试在原有内存后面扩展空间,如果后面不够,它会另找一块大内存并把旧数据拷贝过去。这里最容易翻车的点是直接l->data = realloc(l->data, ...),一旦 realloc 失败返回 NULL,原来的指针就丢了,连 free 都 free 不掉。所以一定要用临时变量接返回值。
参数说明:初始容量一般设 4 或 8,扩容倍数用 2 比较合理——1.5 倍也可以,但 2 倍最容易写也最好解释。size是当前元素个数,capacity是已分配容量,插入前先比较二者,这就是顺序表实验的核心逻辑。
链表这边,头插法和尾插法的选择会影响输出顺序。很多实验题要求“输入一串数,反转输出”,用头插法建链表天然就是逆序;尾插法则保持原顺序。不要把这两个搞混,答辩时老师最喜欢问的就是“你这里为什么和输入顺序反了”。
3.2 栈与队列:括号匹配和循环队列的边界
栈的实验题经典是“括号匹配”,队列的实验题经典是“循环队列”。这两道题考的都是同一个能力:边界条件的判断。
int is_balanced(const char* s) { char stack[1000]; int top = -1; for (int i = 0; s[i]; i++) { if (s[i] == '(' || s[i] == '[' || s[i] == '{') { stack[++top] = s[i]; } else if (s[i] == ')' || s[i] == ']' || s[i] == '}') { if (top < 0) return 0; // 右括号先出现 char left = stack[top--]; if ((left == '(' && s[i] != ')') || (left == '[' && s[i] != ']') || (left == '{' && s[i] != '}')) return 0; } } return top == -1; // 左括号没配完也是错 }逻辑说明:用数组模拟栈,top从 -1 开始,入栈先++top,出栈先取stack[top--]。这个写法比“top 从 0 开始,入栈先赋值再 top++” 更直观,也更不容易出数组越界。
参数说明:栈的容量 1000 是固定上限,够应付课堂题目。如果题目输入串很长,建议直接malloc动态栈。三个关键判断:右括号出现时栈已空,说明不匹配;配对时左右括号类型不同,说明不匹配;字符串遍历完但栈里还有东西,说明有左括号没闭合。这三个条件少一个,程序就会在某些测试用例上翻车——这也是实验测试数据的常见套路。
循环队列的写法要点是“浪费一个空间”来区分空和满:front == rear表示空,(rear + 1) % MAXSIZE == front表示满。这个设计虽然浪费一个数组元素,但代码极其干净,比用 size 变量记录个数更不容易写错。
3.3 二叉树:递归写法与非递归写法都要会
二叉树实验一般分两层:先要求实现递归的前序、中序、后序遍历,再要求用非递归重新实现一遍。递归本身很简单,难的是非递归,它考的是对栈的抽象理解。
void inorder_stack(TreeNode* root) { TreeNode* stack[1000]; int top = -1; TreeNode* cur = root; while (cur || top >= 0) { while (cur) { stack[++top] = cur; // 一路往左走,边走的边压栈 cur = cur->left; } cur = stack[top--]; // 栈顶就是最左节点 printf("%d ", cur->val); // 访问它 cur = cur->right; // 转向右子树 } }逻辑说明:非递归中序遍历的核心是“模拟系统栈的行为”。递归版本里,函数调用栈帮我们记住了每个节点访问到哪一步;非递归版本用显式栈替代这个记忆过程。外层 while 的条件cur || top >= 0包含两种状态:当前节点不空,或者栈不空。两个都为空时,说明整棵树遍历完了。
参数说明:栈数组stack[1000]的容量对应树高,高度超过 1000 的非平衡树会越界。真正的生产级代码应该用动态栈,实验课里固定数组够了,但你要知道这个限制。前序和后序的非递归写法各有各的细节,前序好写,后序需要在节点里加一个“右子树是否已被访问过”的标记,或者用两个栈,这部分如果你能独立写出来,答辩基本稳了。
还有一类进阶实验是“根据先序和中序重建二叉树”,核心是递归划分子树范围。写的时候注意区间开闭——我用的是左闭右开[inL, inR),这样空区间判断统一写成inL >= inR,不容易出错。
3.4 图:邻接矩阵还是邻接表
图相关的实验题一般围绕两个方向:遍历(DFS/BFS)和最短路。先说选型:邻接矩阵适合稠密图,判断两点是否相邻是 O(1);邻接表适合稀疏图,省内存。实验课里的测试数据通常很小,邻接矩阵往往更省事,但如果题目给出的顶点数达到几千,矩阵就装不下了。
void bfs(int start, int n, int adj[][MAXN]) { int queue[MAXN]; int head = 0, tail = 0; int visited[MAXN] = {0}; queue[tail++] = start; visited[start] = 1; while (head < tail) { int v = queue[head++]; printf("%d ", v); for (int i = 0; i < n; i++) { if (adj[v][i] && !visited[i]) { queue[tail++] = i; visited[i] = 1; } } } }逻辑说明:BFS 用队列保存“待访问的节点”,visited数组防止重复访问。这里有个细节:在入队时标记 visited,而不是在出队时标记。如果出队才标记,同一个节点会被多个邻居重复入队,队列里出现大量冗余,在图上表现为输出顺序错乱。
参数说明:adj[v][i]为 1 表示 v 到 i 有边,MAXN是顶点数上限。BFS 的时间复杂度是 O(V+E),用邻接表时遍历邻居的数量等于该节点的度,比邻接矩阵的 O(V) 少很多。最短路部分,Dijkstra 经典但要注意:它不能处理负权边,实验题里如果带负权,得用 Bellman-Ford 或 SPFA。别把这两个算法混了——这是图实验里最常被问倒的地方。
3.5 排序与查找:手写快排的三要素
排序实验在课程里属于“必有一个”的存在,快排是高频考题。网上能找到各种版本的快排,但实验评分看的不是代码能跑,而是你能否解释清楚三个关键点:pivot 怎么选、分区怎么做、递归出口怎么定。
void quick_sort(int a[], int lo, int hi) { if (lo >= hi) return; // 空区间或单元素 int mid = lo + (hi - lo) / 2; // 三数取中 if (a[mid] < a[lo]) swap(a, lo, mid); if (a[hi] < a[lo]) swap(a, lo, hi); if (a[hi] < a[mid]) swap(a, hi, mid); int pivot = a[mid]; swap(a, mid, hi); // pivot 放到最后 int i = lo - 1; for (int j = lo; j < hi; j++) { if (a[j] < pivot) { i++; swap(a, i, j); } } swap(a, i + 1, hi); // pivot 归位 quick_sort(a, lo, i); quick_sort(a, i + 2, hi); }逻辑说明:这是洛穆托分区的快排写法,pivot 固定取最后一个元素会让有序数组的退化到 O(n²),所以先用三数取中打乱数据分布。交换之后,i指向最后一个小于 pivot 的元素,i + 1是 pivot 的最终位置。
参数说明:lo和hi是闭区间 [lo, hi]。递归出口是lo >= hi,不是lo == hi——当i等于lo - 1时,会让后半个区间的起点变成lo,此时会发生无限递归,必须用>=拦掉。三数取中不是必须的,实验题如果没要求优化,直接就选a[hi]也行,但你要能说出来这样写在最坏情况下的问题。
查找部分,二分查找是最常考的。while (lo <= hi)和while (lo < hi)不是一回事,前者是闭区间,后者是左闭右开。选一种区间定义然后全程保持一致,比死记“要不要加 1”可靠得多。
4. 调试与验证:让程序开口说话
4.1 打印插桩法:给关键路径加“探针”
很多同学遇到程序崩溃,第一反应是盯着代码看,试图用肉眼找 bug。这种做法效率极低。正确做法是在关键路径上插打印语句,把程序执行过程暴露出来。链表插入时打印“当前插到第几个节点,前驱是谁”,二叉树遍历时打印“当前访问哪个节点”,这些输出会直接告诉你程序到底走到哪一步才崩的。
Node* pre = head; printf("[DEBUG] insert pos=%d, start\n", pos); for (int i = 0; i < pos && pre->next; i++) { pre = pre->next; printf("[DEBUG] step %d, current node=%d\n", i, pre->data); } printf("[DEBUG] insert before=%d\n", pre->data);调试完记得删掉这些 printf,否则提交时输出格式不对,OJ 判题直接给零分。这算是最常见的翻车现场——程序逻辑全对,就因为多打了调试信息。
4.2 内存泄漏检查:两种工具两条路
Linux 下最简单的内存检查工具是 Valgrind,一条命令就能跑完:
gcc -g -o demo main.c list.c valgrind --leak-check=full ./demo看到definitely lost: 0 bytes说明没有内存泄漏。Windows 下没有 Valgrind,但 VS 自带 CRT 内存泄漏检测:
#define _CRTDBG_MAP_ALLOC #include <crtdbg.h> int main(void) { _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF); // 你的业务代码 return 0; }这两行代码加在 main 开头,程序退出时调试器输出窗口会显示哪一行 malloc 没被 free。实验答辩时,主动说“我用 Valgrind 测过没有内存泄漏”,比说“我试了几次都能跑”有说服力得多。
4.3 边界测试清单:不要只测老师给的样例
实验题给的样例通常只有一组正常数据,但测试数据几乎必然包含边界情况。我给自己定了一个最小测试清单:空结构、只有一个元素、插入到头部、插入到尾部、删除最后一个元素、连续插入后删除全部。这个清单对线性表、栈、队列都适用。
二叉树多测一个“单节点树”和“高度很大的斜树”,后者验证递归会不会爆栈。图多测一个“无边的孤立顶点”。排序多测“已经有序的数组”和“全部相等的数组”。这些边界条件才是拉开分差的地方——基础功能大家都会写,边界条件才是能力分界线。
5. 避坑:数据结构实验的常见问题与排查
5.1 程序一运行就崩溃,连 printf 都不输出
现象:双击运行或 OJ 提交直接报运行时错误,代码里第一行 printf 都没执行。
原因:往往是全局变量定义太大,或者 malloc 失败后没有检查空指针。全局数组超过几 MB 时,不同平台的栈区大小不一致,有些环境直接秒崩。
解决:检查是否有大数组,改成 malloc 动态分配。malloc 之后一律判断返回值,空指针就打印错误并退出。另外检查 main 函数是不是写成了void main(),某些编译器拒绝这种写法,改成int main(void)加return 0;。
5.2 输出中文乱码
现象:printf 里写了中文,控制台显示一堆火星文。
原因:源码文件编码是 UTF-8,Windows 控制台用的却是 GBK。VS 的“高级保存选项”可以改编码,但你直接改成 GBK 保存后可能在别的地方显示乱码。
解决:最省事的是调试阶段全部用英文输出,报告里贴截图时再写中文说明。这也是很多大佬的实验代码里注释全英文的原因,不是为了装,是为了省掉编码问题。
5.3 scanf 读字符串遇到空格就断
现象:输入"hello world"用scanf("%s", s)只读到了"hello"。
原因:%s读到空白字符就停止,这是标准行为。
解决:读整行用fgets(s, sizeof(s), stdin),注意它会保留末尾换行符,要手动去掉:
char s[100]; fgets(s, sizeof(s), stdin); s[strcspn(s, "\n")] = 0;strcspn返回换行符的下标,直接置 0。这是字符串实验里最值得记的一行代码。
5.4 递归深度一大就跑不动的段错误
现象:二叉树高度 5000 的斜树,递归遍历直接段错误。
原因:每次递归调用都占用栈帧,系统栈空间有限。默认栈大小 Windows 是 1MB,深递归很快耗尽。
解决:改成非递归遍历,用显式栈。实验报告里可以顺手讨论“递归 vs 非递归的时空权衡”,这是加分项。另外注意调试时栈深度比运行时更敏感,同一个程序调试器里崩,命令行跑可能没事。
5.5 代码在 Dev-C++ 里能跑,换到 OJ 就编译失败
现象:本地运行一切正常,提交 OJ 报Compile Error。
原因:本地编译器放宽了某些语法检查,OJ 的编译器版本更严格。常见的坑包括:gets()函数在 C11 标准已被移除、for (int i = 0; ...)在 C90 不允许、注释里存在非 ASCII 字符被当成非法 token。
解决:写的时候就按 C11 标准来,不用gets,全部变量在使用前声明。提交前打开编译警告开关,gcc -Wall -Wextra,把所有 warning 当 error 处理。这一步能拦截一半以上的隐藏问题。
6. 进阶:把二叉树可视化打印出来的小技巧
二叉树实验调起来最痛苦的是——你心里知道这棵树长什么样,但程序输出一行前序遍历,你根本对不上号。我后来养成了一个习惯:给二叉树写一个层次打印函数,每层一行,用缩进表示层级关系。这个函数花二十分钟写一次,之后所有树相关的实验都能复用。
void tree_print(TreeNode* root) { if (!root) { printf("(empty)\n"); return; } TreeNode* queue[1000]; int head = 0, tail = 0; queue[tail++] = root; while (head < tail) { int level_size = tail - head; for (int i = 0; i < level_size; i++) { TreeNode* node = queue[head++]; if (node) { printf("%d ", node->val); queue[tail++] = node->left; queue[tail++] = node->right; } else { printf("# "); } } printf("\n"); } }这段代码用队列做层次遍历,level_size记录当前层有多少个节点,打印完一层就换行。空节点打印成#,这样你能直观看到左子树和右子树的位置,重构二叉树时尤其好用。
参数说明:这个写法在节点满 1000 个时会越界,实验课足够用。如果树的规模不确定,queue 改 malloc。另一个细节:#是占位符,但它不参与树的重建,只是帮助你视觉核对。真正的反序列化需要把#转成 NULL 节点,那是另一个实验题,思路是一样的。
这是我在数据结构实验上最大的习惯转变:不要急着写业务逻辑,先写一个可视化工具。链表就打印节点序列,树就打印层次结构,图就打印邻接矩阵。工具写好后,数据结构“长什么样”一目了然,调试时间能缩短一半。这个习惯一直带到了之后的工作里,写任何复杂模块都先搭一个可视化验证环境。希望这篇笔记能帮你在实验上少走点弯路。
本文还有配套的精品资源,点击获取