简介:这份「实验-数据结构程序」压缩包面向计算机专业学生与算法初学者,聚焦排序、查找及链式数据结构的上机实践,帮助读者通过可运行代码理解抽象概念。包内共61个文件,以32个cpp源文件、13个头文件与16张参考书封面图为主,源码覆盖交换排序、选择排序、插入排序、折半查找、顺序查找、散列查找,以及单链表、链队列、顺序栈、二叉树、邻接矩阵与邻接表等验证实验,头文件与实现分离,便于对照阅读与二次修改。压缩包约4MB,已有315人学习下载。读者可借此获得一套结构清晰的数据结构实验代码集,直接编译运行观察算法执行过程,结合配套教材封面按图索骥,快速完成课程实验与期末复习,夯实算法与数据组织的基本功。
1. 拆开「实验-数据结构程序.7z」:一个压缩包里到底该有什么
很多人第一次看到「实验-数据结构程序.7z」这个标题,会下意识以为里面就是几份实验报告和一堆.c文件。真拿到手、解压开、准备照着跑一遍的时候,才发现事情没那么简单:编译报错、头文件找不到、输入数据对不上、链表跑着跑着就段错误。数据结构实验从来不是「抄一份代码交上去」这么轻松,它是一整套从抽象数据类型到可运行程序的完整链路。
这个标题背后真正指向的,是计算机专业最核心的一门基础课——数据结构——的动手环节。它要解决的是:把线性表、栈、队列、树、图、查找、排序这些抽象结构,用 C 或 C++ 真正写成能编译、能跑、能验证的程序。适合谁?正在做课程实验的本科生、准备 408 数据结构代码题的考研人、以及想用 C 语言把数据结构重新捡起来的一线开发者。下面我按「包里该有什么 → 怎么跑通 → 坑在哪 → 怎么进阶」的顺序,把这件事讲透。
2. 数据结构实验包的目录结构与编译环境:先让程序跑起来
2.1 一个能直接用的实验包应该长什么样
拿到「实验-数据结构程序.7z」这类压缩包,别急着双击main.c。先看目录结构,一个组织良好的数据结构实验包通常是这样分层的:
数据结构实验/ ├── include/ # 头文件:结构定义与函数声明 │ ├── seqlist.h │ ├── linklist.h │ ├── stack.h │ ├── btree.h │ └── graph.h ├── src/ # 源文件:各结构的实现 │ ├── seqlist.c │ ├── linklist.c │ ├── stack.c │ ├── btree.c │ └── graph.c ├── test/ # 测试入口:每个实验一个 main │ ├── test_seqlist.c │ ├── test_linklist.c │ └── test_sort.c ├── data/ # 输入数据文件 │ └── input.txt ├── Makefile └── README.md这个分层不是摆设。头文件放结构体定义和函数原型,源文件放实现,测试文件放main,三者分离才能让同一个链表实现被多个实验复用。如果你的包里所有代码都堆在一个.c文件里,几百行揉在一起,那基本可以判断这是一份「能交差但没法维护」的作业,后面调试会很痛苦。
提示:解压后先执行
find . -name "*.c" | wc -l和find . -name "*.h" | wc -l,对代码规模有个数。文件数太少(比如只有 1~2 个.c)说明结构没拆开,文件数太多(几十个)要警惕是不是混进了无关内容。
2.2 用 Makefile 一次编译全部实验
数据结构实验最常见的翻车点不是算法写错,而是编译环境没配好。Windows 上用 Dev-C++ 或 VS 的人,换到 Linux 或 Mac 上直接gcc main.c往往一堆undefined reference。正确做法是写一个 Makefile,把编译规则固定下来:
CC = gcc CFLAGS = -Wall -g -Iinclude SRCDIR = src TESTDIR = test OBJS = $(SRCDIR)/seqlist.o $(SRCDIR)/linklist.o $(SRCDIR)/stack.o # 默认目标:编译所有测试 all: test_seqlist test_linklist test_sort test_seqlist: $(TESTDIR)/test_seqlist.c $(SRCDIR)/seqlist.o $(CC) $(CFLAGS) -o $@ $^ test_linklist: $(TESTDIR)/test_linklist.c $(SRCDIR)/linklist.o $(CC) $(CFLAGS) -o $@ $^ test_sort: $(TESTDIR)/test_sort.c $(CC) $(CFLAGS) -o $@ $^ %.o: %.c $(CC) $(CFLAGS) -c -o $@ $< clean: rm -f $(SRCDIR)/*.o test_seqlist test_linklist test_sort这段 Makefile 的关键点有三个。第一,-Iinclude告诉编译器去include/目录找头文件,这样源码里写#include "seqlist.h"就能找到,不用写一长串相对路径。第二,-Wall -g打开所有警告并保留调试符号,-g是后面用 gdb 调试的前提,很多人编译时不加,出了段错误只能干瞪眼。第三,把每个结构单独编译成.o再链接,改一个文件只重编一个,比每次全量编译快得多。
执行make之后如果报undefined reference to 'InitList',八成是某个.o没参与链接,检查目标文件依赖里是不是漏了对应的源文件。如果报fatal error: seqlist.h: No such file or directory,就是-I路径写错了,用pwd确认当前目录,再核对include的实际位置。
2.3 用 gcc 手动编译单个实验的完整命令
不想写 Makefile 也行,单个实验手动编译就一条命令,但参数要写全:
gcc -Wall -g -Iinclude \ test/test_linklist.c \ src/linklist.c \ -o test_linklist-Iinclude指定头文件搜索路径,两个.c文件一起编译链接,-o指定输出可执行文件名。编译成功后./test_linklist运行。如果程序需要读输入文件,用重定向:./test_linklist < data/input.txt。这一步看着简单,但新手最常犯的错是只编译test_linklist.c而忘了带上linklist.c,结果链接阶段报一堆未定义符号。记住:用到哪个结构的函数,就要把那个结构的源文件一起编译。
3. 线性表与链表的实验实现:从数组到指针的第一次翻车
3.1 顺序表:为什么length和数组下标总差一
顺序表是数据结构实验的第一个坎,看着简单,但边界处理能把人绕晕。核心就一个结构体加一组操作:
#define MAXSIZE 100 typedef struct { int data[MAXSIZE]; int length; /* 当前元素个数,不是最大下标 */ } SeqList; /* 在第 i 个位置插入元素 e,i 从 1 开始计数 */ int ListInsert(SeqList *L, int i, int e) { if (i < 1 || i > L->length + 1) return 0; /* 位置非法 */ if (L->length >= MAXSIZE) return 0; /* 表满 */ for (int j = L->length; j >= i; j--) { L->data[j] = L->data[j - 1]; /* 后移,注意从后往前 */ } L->data[i - 1] = e; L->length++; return 1; }这里有两个必须讲清的点。第一,length是元素个数,合法插入位置是1到length+1,而数组下标是0到length-1,两者差一,几乎所有顺序表 bug 都出在这个「差一」上。第二,后移元素必须从后往前循环,如果写成从前往后,data[i]会被data[i-1]覆盖,数据就丢了。删除操作同理,是从前往后前移,方向正好相反。
参数说明:L是指向顺序表的指针,用指针是为了让函数内修改length能反映到调用方;i是逻辑位置(从 1 开始),e是待插入值。返回值用int表示成功失败,比void好,调用方能判断。测试时重点测三个边界:空表插入、表满插入、在表头i=1和表尾i=length+1插入。
3.2 单链表:头结点到底要不要
链表实验最大的争议就是「要不要头结点」。我的建议是:实验代码一律带头结点。带头结点后,插入和删除在第一个位置和中间位置的处理逻辑统一了,不用单独判断「是不是在表头操作」,代码能少一半if。
typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; /* 头插法建表,带头结点 */ LinkList List_HeadInsert(LinkList L) { LNode *s; int x; L = (LinkList)malloc(sizeof(LNode)); L->next = NULL; /* 先建头结点 */ scanf("%d", &x); while (x != 9999) { /* 9999 作为结束标志 */ s = (LNode *)malloc(sizeof(LNode)); s->data = x; s->next = L->next; /* 新结点指向原首结点 */ L->next = s; /* 头结点指向新结点 */ scanf("%d", &x); } return L; }头插法的结果是输入顺序和链表顺序相反,尾插法则一致。这里9999是约定的输入结束标志,实际实验里可能是文件读到 EOF,用while (scanf("%d", &x) != EOF)更通用。malloc之后一定要检查是否返回NULL,虽然实验数据量小基本不会失败,但养成习惯。释放链表时要从前往后逐个free,不能只free头结点,否则整条链的内存全泄漏。
3.3 用 gdb 定位链表段错误
链表跑出Segmentation fault是家常便饭,血泪经验是:别靠 printf 猜,直接用 gdb。编译时带了-g,就可以这样定位:
gdb ./test_linklist (gdb) run # 程序崩溃后 (gdb) bt # 打印调用栈,看崩在哪个函数 (gdb) frame 0 # 切到崩溃的那一帧 (gdb) print s # 打印指针变量 s 的值 (gdb) print s->next # 看是不是访问了空指针bt(backtrace)告诉你崩溃发生在哪个函数的哪一行,print看指针是不是NULL或者野指针。链表段错误九成是两种情况:访问了NULL->next,或者用了已经free掉的内存。前者通常是循环条件写成了while (p->next != NULL)但p本身已经是NULL;后者是释放后没把指针置NULL,后面又用了一次。gdb 里print一个指针,如果显示0x0就是空指针,显示一个奇怪地址多半是野指针。
4. 树、图与排序实验:递归、遍历和复杂度验证
4.1 二叉树的三种遍历与递归调试
二叉树实验的核心是递归,而递归最容易让人「脑子转不过来」。先定义结点,再写遍历:
typedef struct BiTNode { int data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; /* 中序遍历:左-根-右 */ void InOrder(BiTree T) { if (T != NULL) { InOrder(T->lchild); printf("%d ", T->data); InOrder(T->rchild); } }先序、中序、后序的区别只是printf那一行的位置:放最前是先序,中间是中序,最后是后序。理解递归不要一行行追,要相信「InOrder(T->lchild)会把左子树完整遍历完」这个假设,这就是递归的信任跳跃。调试递归时,可以在函数入口加一句printf("enter node %d\n", T ? T->data : -1),看进入和返回的顺序,对照手算的遍历序列。如果输出顺序不对,先检查建树时左右孩子有没有接反,再检查递归调用顺序。
4.2 图的两种存储与遍历选择
图实验第一个决策是选邻接矩阵还是邻接表。顶点少(几十个以内)、边密集,用邻接矩阵,代码简单,判断两点是否相邻是 O(1);顶点多、边稀疏,用邻接表,省空间。实验里两种都要求实现,对比着写最能理解差异。
#define MAXV 100 typedef struct { int edges[MAXV][MAXV]; /* 邻接矩阵,0 表示无边 */ int vexnum, edgenum; } MGraph; /* DFS,递归实现 */ void DFS(MGraph G, int v, int visited[]) { visited[v] = 1; printf("%d ", v); for (int w = 0; w < G.vexnum; w++) { if (G.edges[v][w] != 0 && !visited[v]) { /* 注意这里 */ DFS(G, w, visited); } } }上面这段代码里!visited[v]是故意留的一个典型错误——正确写法应该是!visited[w]。判断的是「邻接点 w 有没有被访问过」,不是当前点 v。这种笔误在实验里极常见,DFS 会陷入死循环或者漏访问。BFS 则要用队列,把起点入队,出队时把未访问的邻接点全部入队,一层层扩散。两种遍历都要用一个visited数组标记,且每次遍历前要清零,否则第二次遍历会一个点都访问不到。
4.3 排序算法的实测对比:别只看书上的复杂度
排序实验最有价值的部分不是把代码敲出来,而是实测不同规模下的运行时间,验证 O(n²) 和 O(n log n) 的差距。写一个计时框架:
#include <time.h> #include <stdlib.h> void test_sort(void (*sort_fn)(int[], int), int a[], int n, const char *name) { int *copy = (int *)malloc(n * sizeof(int)); for (int i = 0; i < n; i++) copy[i] = a[i]; /* 每次用原始数据副本 */ clock_t start = clock(); sort_fn(copy, n); clock_t end = clock(); double ms = (double)(end - start) / CLOCKS_PER_SEC * 1000; printf("%s: n=%d, %.2f ms\n", name, n, ms); free(copy); }关键点是每次排序都要用原始数据的副本,因为排序会改变数组,如果对同一个数组连续测多种算法,后面的算法拿到的是已经排好序的数据,结果完全失真。clock()测的是 CPU 时间,精度够实验用。用rand()生成 1000、10000、100000 三档规模,你会看到冒泡排序在 10 万数据时慢到无法忍受,而快排在毫秒级完成,这个直观冲击比看复杂度公式强得多。
| 算法 | 平均复杂度 | 10^4 实测 | 10^5 实测 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | 约 100 ms | 约 10 s | 稳定 |
| 插入排序 | O(n²) | 约 50 ms | 约 5 s | 稳定 |
| 快速排序 | O(n log n) | 约 1 ms | 约 15 ms | 不稳定 |
| 归并排序 | O(n log n) | 约 2 ms | 约 20 ms | 稳定 |
表格里的数值是量级参考,具体机器不同会有差异,但数量级关系是稳定的。实验报告里放这样一张实测表,比抄书上的复杂度表有说服力得多。
5. 数据结构实验的避坑清单:那些让程序跑不起来的细节
5.1 头文件重复包含导致重定义
现象:编译报redefinition of 'struct Node'或multiple definition of 'InitList'。原因:同一个头文件被多个源文件包含,或者头文件里直接定义了变量/函数体,没有加包含守卫。解决:每个头文件开头加
#ifndef SEQLIST_H #define SEQLIST_H /* 结构定义与函数声明 */ #endif同时记住一个原则:头文件里只放声明,不放定义。函数体放.c文件,全局变量用extern声明。如果确实要在头文件里定义static inline函数,那没问题,但普通函数定义放头文件,被两个.c包含就会重定义。
5.2 scanf 读字符串和数字混用导致输入错乱
现象:程序读输入时跳过某些数据,或者把上一次的输入重复读进来。原因:scanf("%d", &x)读数字时会把换行符留在缓冲区,后面用scanf("%c", &c)或gets读字符时,直接读到了那个残留的换行符。解决:读字符前加getchar()吃掉换行,或者统一用fgets读整行再解析。数据结构实验里输入格式往往是「先读个数,再读一串数」,混用%d和%c时尤其容易中招。
5.3 malloc 后不检查、free 后不置空
现象:程序偶尔崩溃,或者运行一段时间后内存占用飙升。原因:malloc返回NULL时直接解引用,或者free(p)之后又用p。解决:malloc后立即判断if (p == NULL) { ... };free(p)后紧跟p = NULL;。链表和树的实验里,删除结点、销毁整棵树时最容易漏掉置空,导致后面误用野指针。这个习惯在实验阶段看着多余,但它是区分「能跑」和「可靠」的分水岭。
5.4 递归没有终止条件或栈溢出
现象:程序卡死、崩溃,或者报stack overflow。原因:递归函数缺少终止条件,或者终止条件写错(比如二叉树遍历里判断了T->lchild却没判断T本身)。解决:递归函数第一行永远是终止条件判断,且要判断的是当前结点是否为空,不是它的孩子。树的深度过大时(比如退化成链的二叉搜索树),递归深度可能超过默认栈大小,这时要么改非递归,要么调整栈限制。实验数据一般不会触发,但理解这一点对写生产代码很重要。
5.5 排序比较函数写反导致结果逆序
现象:排序跑完,数组是降序而不是升序。原因:比较函数里return a > b和return a < b写反了,或者手写快排时while (a[i] < pivot)的方向搞错。解决:统一约定「升序时,前面的元素小于等于后面的元素」,比较函数返回「a 应该排在 b 前面」的条件。写完先用 3 个元素的小数组手测,确认顺序对了再上大规模数据。这个坑小但高频,尤其是用qsort时比较函数的返回值语义容易记混。
6. 把实验包变成自己的代码库:验证与进阶技巧
实验做完就扔,是最大的浪费。我的习惯是:每完成一个数据结构,就把它整理成一个可复用的模块,配上单元测试,攒成自己的代码库。具体做法是给每个结构写一个测试文件,用断言验证边界行为,而不是靠肉眼打印。
#include <assert.h> #include "seqlist.h" void test_seqlist(void) { SeqList L; InitList(&L); assert(L.length == 0); /* 空表长度为 0 */ assert(ListInsert(&L, 1, 10) == 1); /* 空表插入成功 */ assert(L.data[0] == 10); assert(ListInsert(&L, 3, 20) == 0); /* 位置越界,应失败 */ assert(ListDelete(&L, 1, NULL) == 1); assert(L.length == 0); printf("seqlist tests passed\n"); }用assert的好处是:测试失败会直接告诉你哪一行断言不成立,比打印一堆数字再自己比对高效得多。把每个结构的测试都写成这样的函数,make test一次跑完,改代码后立刻知道有没有破坏原有功能。这套习惯坚持下来,408 数据结构代码题和课程设计都能直接调用自己验证过的模块,不用每次从零写起。
进阶方向有两个。一是把递归改成非递归,比如用栈模拟二叉树遍历、用队列实现层序遍历,这个过程能逼你真正理解递归背后的调用栈。二是给结构加上性能统计,比如在链表操作里统计比较次数和移动次数,用数据验证「顺序表插入平均移动 n/2 个元素」这类结论。我当年做实验时嫌麻烦没写测试,结果课程设计时链表删除的边界 bug 反复出现,回头补测试花的时间比一开始就写多得多。这个后悔药,希望你别吃。把实验包当成起点而不是终点,它才真正值钱。希望帮到你。
本文还有配套的精品资源,点击获取