简介面向计算机专业学生与数据结构初学者这份资料围绕华中科技大学计算机学院数据结构实验以C语言实现顺序表、单链表、二叉树与邻接表无向图四类核心结构帮助读者把课堂概念落到代码操作上。压缩包共4个文件均为.c源码整体约17KB分别对应四个实验的创建、插入、删除、查找、遍历等基础实现便于对照课程进度逐项练习。已有575人学习说明其在课程实验与复习场景中具有一定参考价值。读者可从中理解数组连续存储的插入删除代价、链表节点的指针连接、二叉树递归遍历逻辑以及图的深度优先与广度优先搜索思路并借由代码细节体会不同结构的时间复杂度与适用场景适合作为实验参考、课后巩固和考前梳理的轻量材料。1. 数据结构实验包长什么样五个模块、一份课程设计底稿某高校计算机学院的数据结构实验最大的特点是“课听得懂上机就写不出来”。链表插入的图画得明明白白轮到自己写双向循环链表的删除指针顺序一错程序直接返回 Segmentation fault调半小时也找不到是哪一行。这份 C 语言数据结构实验包的价值就在于涵盖了线性表、栈与队列、树、图、查找排序五类结构从接口定义到完整实现的整个流程还带了一批可直接运行的测试入口。它适合两类人一是正在做课程实验、需要一个能对照的正确实现二是已经工作、想把 C 语言指针和内存管理重新系统过一遍的开发者。2. 从压缩包到可执行文件C 语言工程的环境落地与目录梳理拿到这类实验包第一反应不要是打开某一个 .c 文件就开始抄。真正决定后面顺不顺的是目录结构和编译方式。这两件事没理顺后面每改一个函数都要重新拼一遍 gcc 命令效率会被拖垮。2.1 先读目录再读代码模块边界在哪里典型的实验包会按章节拆成五到六个子目录每个子目录内部遵循“一个头文件 一个实现文件 一个测试主程序”的三件套结构。这种结构的用意是把“结构的定义”和“结构的测试”彻底分开改实现的时候不碰测试改测试的时候不碰实现。下表的目录划分来自我拆过的多数 C 语言实验包具体名称可能略有差异但职责基本一一对应目录主要内容对外暴露的头文件依赖list/顺序表、单链表、双向链表seqlist.h、linklist.h无stack_queue/顺序栈、链栈、循环队列sqstack.h、sqqueue.h无tree/二叉树建树、四种遍历、线索化bitree.h无graph/邻接矩阵、邻接表、DFS/BFS、最小生成树algraph.h无search_sort/折半查找、哈希表、六种排序search.h、sort.h无common/状态码定义、计时工具、随机数据生成status.h、util.h无判断一个实验包值不值得细读看两点就够头文件里有没有统一的返回值约定比如 0 成功、负数失败以及有没有一个公共的 status.h 把 ElemType 统一定义成宏。这两点都做到了说明作者是有工程意识的不是把课件代码直接打包。读的顺序建议反过来先看 common/status.h 搞清楚约定再看某个模块的头文件搞清楚接口签名最后才看 .c 里的具体实现。上来就啃实现很容易陷进指针细节里忘了这个函数到底该干什么。2.2 编译选项把警告当错误处理很多人跑实验代码的习惯是gcc main.c -o main能跑就行。这个习惯在数据结构实验里是灾难。指针未初始化、有符号无符号比较、隐式类型转换这三类问题编译器默认只给 warning而它们恰恰是后面段错误的根源。我一般会用下面这行命令做单文件快速验证参数一个都不省# 单模块快速验证打开全部常用警告把警告升级为错误同时挂上地址检查 gcc -stdc11 -Wall -Wextra -Werror -g -O0 \ -fsanitizeaddress,undefined \ -Iinclude \ src/list/seqlist.c src/list/main_seqlist.c \ -o build/seqlist_demo逐个说清楚每个参数为什么加-stdc11固定语言标准。实验包里常见for (int i 0; ...)这种写法用默认的 gnu89 会直接报错先钉死标准能省掉一半的报错干扰。-Wall -Wextra打开绝大多数有用警告-Wextra会额外检查未使用参数、符号比较这类隐蔽问题。-Werror把警告当错误。这条是血泪经验只要允许警告存在指针相关的警告就会被淹没在几十条输出里你根本不会去看。-g -O0保留调试信息并关闭优化。优化会把变量重排用 gdb 单步时行号和源码对不上排查指针问题时非常难受。-fsanitizeaddress,undefined挂上地址检查和未定义行为检查。数组越界、释放后使用、有符号整数溢出这三类问题靠肉眼看代码基本看不出来靠这两个开关能直接定位到行。-Iinclude指定头文件搜索路径让源码里写#include seqlist.h就能找到不用写一长串相对路径。注意-fsanitize和-Werror一起用时某些编译器版本会因为 sanitiser 自身的代码触发警告。如果确实遇到把-Werror换成-Werror -Wno-errorunused-parameter不要直接删掉-Werror。2.3 Makefile多目录工程的最小可用骨架单文件编译命令再长也只适合临时验证。一个包含五六个模块的工程必须有一份 Makefile 把编译规则固定下来。下面这份是我在实验包基础上改出来的最小版本够用且不难读懂CC : gcc CFLAGS : -stdc11 -Wall -Wextra -Werror -g -O0 -Iinclude LDFLAGS : BUILD : build # 每个模块编成一个独立可执行文件互不干扰 TARGETS : $(BUILD)/seqlist_demo \ $(BUILD)/linklist_demo \ $(BUILD)/bitree_demo \ $(BUILD)/graph_demo all: $(TARGETS) # 顺序表演示实现文件 对应的测试主程序 $(BUILD)/seqlist_demo: src/list/seqlist.c src/list/main_seqlist.c mkdir -p $(BUILD) $(CC) $(CFLAGS) $^ $(LDFLAGS) -o $ $(BUILD)/linklist_demo: src/list/linklist.c src/list/main_linklist.c mkdir -p $(BUILD) $(CC) $(CFLAGS) $^ $(LDFLAGS) -o $ $(BUILD)/bitree_demo: src/tree/bitree.c src/tree/main_bitree.c mkdir -p $(BUILD) $(CC) $(CFLAGS) $^ $(LDFLAGS) -o $ $(BUILD)/graph_demo: src/graph/algraph.c src/graph/main_algraph.c mkdir -p $(BUILD) $(CC) $(CFLAGS) $^ $(LDFLAGS) -o $ # 带内存检查的调试版本单独一个目标不影响正常编译速度 asan: CFLAGS -fsanitizeaddress,undefined asan: clean all clean: rm -rf $(BUILD) .PHONY: all asan clean这份 Makefile 有两个设计取舍值得说。一是每个模块单独产出可执行文件而不是把所有 .c 编成静态库再链接。实验阶段的代码经常要改独立目标意味着改一行只重编那一个迭代速度快得多。二是把-fsanitize放在独立的asan目标里日常编译不挂检查速度快怀疑有内存问题时执行make asan编译产物带检查两套互不干扰。$^表示所有依赖文件$表示目标名这两个自动变量是 Makefile 里最常用的。命令前的表示不回显这条命令本身输出更干净。2.4 跑通第一个程序顺序表初始化与打印工程骨架搭好之后第一件事是确认工具链没问题。不要直接跑最难的那个模块从顺序表开始因为它没有动态内存出错只可能是数组下标排查成本最低。先写头文件把接口和数据结构固定下来/* include/seqlist.h */ #ifndef SEQLIST_H #define SEQLIST_H #define SEQ_MAX 100 /* 静态容量上限实验作业一般给 100 就够 */ typedef int ElemType; /* 真实工程里换成 typedef后面改类型只改这一行 */ typedef struct { ElemType data[SEQ_MAX]; /* 数据区注意容量不是长度 */ int length; /* 当前元素个数取值 0..SEQ_MAX */ } SeqList; int SeqList_Init(SeqList *L); int SeqList_Insert(SeqList *L, int pos, ElemType e); /* pos 从 1 开始 */ int SeqList_Delete(SeqList *L, int pos, ElemType *e); int SeqList_Locate(const SeqList *L, ElemType e); void SeqList_Print(const SeqList *L); #endif实现文件里初始化函数只有三行但每一行都不能省/* src/list/seqlist.c */ #include stdio.h #include seqlist.h int SeqList_Init(SeqList *L) { if (L NULL) { /* 防御空指针这是所有函数的第一道门 */ return -1; } L-length 0; /* 逻辑长度清零数据区不擦擦不擦都不影响正确性 */ return 0; /* 0 表示成功负数表示失败全工程统一这个约定 */ } void SeqList_Print(const SeqList *L) { if (L NULL) return; printf(length%d, data[, L-length); for (int i 0; i L-length; i) { printf(%d%s, L-data[i], (i L-length - 1) ? : , ); } printf(]\n); } int main(void) { SeqList L; if (SeqList_Init(L) ! 0) { /* 调用方必须检查返回值 */ fprintf(stderr, init failed\n); return 1; } SeqList_Print(L); /* 期望输出length0, data[] */ return 0; }这段代码要说明的是三件事。第一SeqList_Init接收的是指针而不是值因为结构体里有数组按值传递会整体拷贝一百个元素。第二初始化只清length不清data因为数组内容在length之外的区域本来就不可访问擦了是多余开销这也是很多教材示例里没讲清楚的点。第三调用方必须检查返回值实验代码里最常见的写法是直接SeqList_Init(L);然后往下走一旦这里失败后面所有操作都是在未初始化的结构体上做的表现就是随机崩溃。编译并运行确认输出符合预期make build/seqlist_demo ./build/seqlist_demo到这一步工具链、头文件路径、Makefile 规则三件事同时被验证了一遍。后面再出问题基本可以锁定在算法逻辑本身而不是环境。3. 线性表与栈队列容量、长度、头结点的参数取舍线性结构是实验里最容易被轻视的部分。大家都知道链表怎么插但真正写出来能扛住边界测试的比例并不高。差别往往不在思路而在几个参数的定义方式上。3.1 顺序表capacity 和 length 必须是两个独立变量顺序表最典型的一个错误是把“容量”和“长度”合并成一个变量。合并之后你无法区分“表满了”和“表是空的”插入和删除的判断条件全都会错位。正确的做法是容量要么用宏固定要么单独存一个capacity字段length只表示当前有效元素个数。插入是顺序表的核心操作也是边界最密集的地方/* 在 pos 位置插入元素 epos 取值 1..length1 */ int SeqList_Insert(SeqList *L, int pos, ElemType e) { if (L NULL) { return -1; /* 参数非法 */ } if (L-length SEQ_MAX) { return -2; /* 表满 */ } if (pos 1 || pos L-length 1) { return -3; /* 插入位置越界 */ } /* 从最后一个元素开始逐个后移一格必须从后往前否则会覆盖 */ for (int i L-length; i pos; --i) { L-data[i] L-data[i - 1]; } L-data[pos - 1] e; /* 数组下标比逻辑位置小 1 */ L-length; /* 长度最后再加顺序不能反 */ return 0; }参数和边界逐条说明pos的取值范围是1到length1。pos length1表示追加到表尾这是合法操作很多实现会漏掉这个上界导致尾插失败。移动循环的方向必须从length往pos递减。如果写成递增data[pos]会先被data[pos-1]覆盖后续元素全部错位这是顺序表最经典的错误。L-length放在赋值之后。如果提前自增循环上界就变了会多搬一格。三个返回值-1 / -2 / -3分别对应三种失败原因。实验里很多人偷懒统一返回-1结果测试失败时完全不知道是哪一种白花时间。删除操作与插入对称但多一个出参。注意出参也要做空指针判断否则调用方传NULL进来一样会崩。删除的时间复杂度同样是 O(n)平均移动 n/2 个元素这一点在实验报告的复杂度分析里要写清楚不能只写“O(n)”了事。3.2 单链表头结点带来的三个实际便利关于要不要带头结点教材里的说法通常很含糊。从实际写代码的角度看带头结点几乎总是更省事理由有三个第一在表头插入和在表中插入可以共用同一段代码不需要单独判断“是不是第一个结点”第二删除第一个结点时不需要修改调用方的头指针函数签名里不用传二级指针第三空表的表示统一为head-next NULL不用处理head NULL这种特殊情况。代价是多占一个结点的内存以及在遍历时要记得从head-next开始而不是head。这个代价在这个场景下完全可以接受。头插法建表是最能体现头结点价值的写法typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; /* 头插法建立长度为 n 的链表返回头结点指针失败返回 NULL */ LinkList List_CreateHead(int n) { LinkList L (LinkList)malloc(sizeof(LNode)); if (L NULL) { return NULL; } L-next NULL; /* 头结点数据域不用只用来占位 */ for (int i 0; i n; i) { LNode *p (LNode *)malloc(sizeof(LNode)); if (p NULL) { List_Destroy(L); /* 中途失败要把已分配的结点全部释放 */ return NULL; } p-data i; p-next L-next; /* 先接后继 */ L-next p; /* 再改前驱两步顺序不能反 */ } return L; }这段代码里最容易被忽略的是内存分配失败的分支。实验作业的数据量小malloc几乎不会失败所以很多人干脆不判断返回值。但一旦养成这个习惯到了真实工程里就是隐患分配失败返回NULL之后继续写p-data i就是对着空指针解引用必崩。我的建议是从实验阶段就把这个判断写进去成本只有三行。头插法建出来的链表元素顺序和插入顺序相反。如果实验要求保持原顺序用尾插法需要额外维护一个tail指针否则每次插入都要从头遍历总复杂度退化成 O(n²)。释放链表要用一个临时指针保存后继否则释放当前结点之后就再也找不到下一个了void List_Destroy(LinkList L) { while (L ! NULL) { LNode *next L-next; /* 先存后继 */ free(L); /* 再释放当前 */ L next; /* 指针移动到下一个 */ } }这个函数不需要区分头结点和普通结点因为头结点的结构和普通结点完全一样这也是带头结点的又一个好处。如果是不带头结点的链表第一个结点的释放逻辑要单独写一遍。3.3 循环队列判空和判满不能靠同一个条件栈的判空判满很直接一个top变量就够了。队列不行因为队头队尾都在动如果只用front rear判断空那满的时候也是front rear两种情况无法区分。常见解法有三种牺牲一个存储单元、增加一个计数器、增加一个标志位。实验包里一般用第一种因为实现最简单。核心思想是数组实际开MAX1个格子但最多只存MAX个元素这样“队尾加一等于队头”就表示满而“队头等于队尾”只表示空两个条件不再冲突。#define QMAX 50 typedef struct { ElemType data[QMAX 1]; /* 多开一格用来区分队空和队满 */ int front; /* 队头下标指向当前队头元素 */ int rear; /* 队尾下标指向下一个可写位置 */ } SqQueue; int Queue_Init(SqQueue *q) { q-front q-rear 0; return 0; } int Queue_Empty(const SqQueue *q) { return q-front q-rear; } int Queue_Full(const SqQueue *q) { return (q-rear 1) % (QMAX 1) q-front; } int Queue_En(SqQueue *q, ElemType e) { if (Queue_Full(q)) { return -2; /* 队满入队失败 */ } q-data[q-rear] e; q-rear (q-rear 1) % (QMAX 1); /* 取模实现循环下标永远不越界 */ return 0; } int Queue_De(SqQueue *q, ElemType *e) { if (Queue_Empty(q)) { return -3; /* 队空出队失败 */ } *e q-data[q-front]; q-front (q-front 1) % (QMAX 1); return 0; }三个参数的含义要记牢data数组长度是QMAX1取模的基数也是QMAX1但元素的实际上限是QMAX。这三处只要有一处写成QMAX判满条件就会出错表现出来就是“明明只存了 49 个元素第 50 个却入不进去”或者更糟覆盖掉了队头元素。rear的定义是“下一个可写位置”而不是“当前队尾元素”。这个定义下入队时先写data[rear]再移动rear顺序清晰如果定义成“当前队尾元素”入队要变成先移后写很容易和出队逻辑搞混。实验报告里如果被问到rear的含义答错这一点会直接扣分。4. 树与图序列还原、邻接结构和递归释放的三个关键点树和图是实验里代码量最大、递归最深、内存最复杂的部分。前面两部分出问题通常只是结果错这两部分出问题常常是程序直接崩溃或者静默泄漏。4.1 由先序和中序序列还原二叉树这是二叉树实验里出现频率最高的一道题也是递归思路最典型的应用。核心逻辑只有一句话先序序列的第一个元素是根在中序序列里找到这个根左边就是左子树的中序右边就是右子树的中序据此可以算出左右子树的长度再回到先序序列里切分。typedef struct BiTNode { ElemType data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; /* pre/in 为长度 n 的先序、中序序列返回还原出的根结点 */ BiTree BuildByPreIn(const ElemType pre[], const ElemType in[], int n) { if (n 0) { return NULL; /* 空子树递归出口 */ } BiTree root (BiTree)malloc(sizeof(BiTNode)); if (root NULL) { return NULL; } root-data pre[0]; int k 0; while (k n in[k] ! pre[0]) { /* 在中序里定位根 */ k; } if (k n) { /* 找不到说明两个序列不匹配 */ free(root); return NULL; } /* 左子树先序从 pre1 开始中序从 in 开始长度 k */ root-lchild BuildByPreIn(pre 1, in, k); /* 右子树先序跳过根和左子树中序跳过根长度 n-k-1 */ root-rchild BuildByPreIn(pre 1 k, in k 1, n - k - 1); return root; }参数切分是这段代码唯一容易出错的地方逐个对一遍左子树先序起点pre 1因为pre[0]已经被根用掉了。左子树中序起点仍是in长度k因为根左边的k个元素就是左子树。右子树先序起点pre 1 k跳过根和整个左子树。右子树中序起点in k 1跳过左子树和根。右子树长度n - k - 1减去左子树和根。k n这个判断很多人不写。正常情况下输入序列是合法的不会走到这里但如果测试用例里序列有误不写这个判断就会拿越界的k去算指针偏移直接段错误。写上去只多三行排查时能省一小时。这个方法的前提是序列中元素互不相同。如果允许重复元素中序里可能有多个位置等于根需要额外规则来消歧实验包里一般会注明“假设元素唯一”实际做题时要看清这一句。4.2 邻接矩阵还是邻接表按边数和查询模式选图的存储结构选择不是审美问题是复杂度问题。两种结构的差异可以用一张表说清楚对比项邻接矩阵邻接表空间复杂度O(V²)与边数无关O(VE)稀疏图优势明显判断两点是否相邻O(1)直接查matrix[u][v]O(deg(u))需要遍历链表遍历某点所有邻居O(V)即使邻居很少也要扫一整行O(deg(u))只访问真实存在的边加边O(1)O(1)头插法适合的图稠密图、需要频繁查询两点关系稀疏图、需要频繁遍历邻居实现难度低二维数组中需要管理链表结点内存实验里的图规模通常只有几十个顶点两种结构跑起来都看不出差别。但如果是课程设计那种带真实数据的题目比如几百个路口的最短路径稠密图用矩阵、稀疏图用表性能能差出一个数量级。邻接表的加边操作有一个细节值得单独讲无向图的一条边要在两个顶点的链表里各插一次第二次分配内存如果失败第一次插进去的结点必须回滚否则图的结构就处于半完成状态后面遍历会读到不存在的边#define MAXV 100 typedef struct ArcNode { int adjvex; /* 邻接点下标 */ struct ArcNode *nextarc; /* 下一条边 */ } ArcNode; typedef struct VNode { ElemType data; /* 顶点信息 */ ArcNode *firstarc; /* 第一条边 */ } VNode, AdjList[MAXV]; typedef struct { AdjList vertices; int vexnum, arcnum; } ALGraph; /* 无向图加边 (u, v)两个方向都要插 */ int Graph_AddArc(ALGraph *G, int u, int v) { if (u 0 || u G-vexnum || v 0 || v G-vexnum) { return -1; /* 顶点下标越界 */ } ArcNode *p (ArcNode *)malloc(sizeof(ArcNode)); if (p NULL) return -2; p-adjvex v; p-nextarc G-vertices[u].firstarc; /* 头插O(1) */ G-vertices[u].firstarc p; ArcNode *q (ArcNode *)malloc(sizeof(ArcNode)); if (q NULL) { G-vertices[u].firstarc p-nextarc; /* 回滚第一次插入 */ free(p); return -2; } q-adjvex u; q-nextarc G-vertices[v].firstarc; G-vertices[v].firstarc q; G-arcnum; return 0; }注意回滚那一行G-vertices[u].firstarc p-nextarc而不是直接置NULL。因为p是头插进去的它的nextarc指向的是原来链表的第一条边把firstarc恢复成这个值链表就回到了插入前的状态。如果直接置空会把原有的边全部丢掉。4.3 释放与递归深度两个必须动手验证的点二叉树的释放必须用后序先释放左右子树再释放根。这个顺序不能反因为一旦先释放了根root-lchild这个指针本身就悬空了再去访问它属于释放后使用。void BiTree_Destroy(BiTree *T) { if (T NULL || *T NULL) { return; } BiTree_Destroy((*T)-lchild); /* 先左 */ BiTree_Destroy((*T)-rchild); /* 再右 */ free(*T); /* 最后根 */ *T NULL; /* 置空防止二次释放 */ }这里传的是二级指针BiTree *T目的就是最后那行置空。如果只传一级指针函数内部把结点释放了调用方手里的指针还指向那块已释放的内存下次误用就是未定义行为。把指针置空这个动作做在函数里调用方就不需要额外记着这一步——这是我在多次踩坑之后固定下来的写法。另一个容易忽略的是递归深度。树的递归深度等于树高实验数据一般只有十几层不会有问题。但图不一样DFS 的递归深度等于路径长度如果图是一条几百个顶点的链递归就可能把栈撑爆表现为程序无输出直接退出退出码还不固定。遇到这种情况把 DFS 改成显式栈的迭代写法或者确认数据规模后再决定不要一味相信递归。图的释放是两层循环外层遍历顶点内层释放该顶点的边链表写法与单链表释放完全一致。很多人只释放了数组本身忘了释放边链表用地址检查工具一跑就是一片泄漏。5. 避坑排查五个让实验卡到凌晨的翻车现场下面这五条都是我在帮人调试实验代码时反复遇到的按“现象—原因—解决”记下来遇到类似症状可以直接对号入座。5.1 指针与内存类的两个典型现象一程序在free()那一行崩报错信息里提到 invalid pointer 或者 double free。原因通常是释放了一个不是malloc返回的地址。最常见的场景是链表操作里指针被移动过位置比如while (p ! NULL) { free(p); p p-next; }这种写法free(p)之后p-next已经是无效内存了读到的是垃圾值下一次free自然出问题。还有一种是把数组名或者栈上变量的地址传给了free。解决方法是释放前先把后继保存到临时变量再释放当前结点也就是 3.2 节里List_Destroy的写法。另外建议在free之后立刻把指针置成NULL这样即使后面误用访问空指针会马上崩在确定的位置比访问随机地址容易定位得多。现象二函数返回一个局部数组的指针调用方拿到之后数据全是乱码。原因很直接局部变量在栈上函数返回时栈帧就销毁了返回的指针指向一块随时会被覆盖的内存。这种现象的迷惑性在于刚返回时那块内存往往还没被覆盖打印出来居然是对的等调用几次别的函数之后就变成垃圾数据表现非常随机。解决办法只有一条需要返回数据就让调用方传缓冲区进来或者在堆上分配。实验里比较常见的折中是静态数组但静态数组不是线程安全的而且多次调用会互相覆盖只适合单线程演示场景。我现在写这类函数一律走“调用方提供缓冲区 长度参数”的签名虽然麻烦一点但不会出玄学问题。5.2 边界与逻辑类的两个典型现象三链表删除操作删掉了不该删的结点或者删完之后链表断了。原因基本都出在删除时的指针顺序。删除结点p的正确顺序是先让前驱指向p的后继再释放p。如果先释放p再改前驱前驱要指向的p-next已经是无效内存。另一种情况是删除头结点时没有更新头指针函数返回后调用方手里的头指针还指向已释放的结点。解决办法是把删除操作拆成“找前驱”和“改指针”两步中间不要插入任何释放动作。对于要修改头指针的情况要么用二级指针要么用带头结点的链表绕开这个问题——这也是我在 3.2 节里推荐带头结点的实际理由之一。现象四循环队列在存满之后再入队一个元素不报错但取出来的数据错位。原因是判满条件写错。常见错误是把取模基数写成QMAX而不是QMAX1或者rear和front的更新顺序搞反。还有一种是把队列当成栈来用front和rear都从同一个方向移动。解决办法是先手算一遍容量 50 的循环队列最多能存 50 个元素此时(rear 1) % 51 front必须成立。拿笔在纸上把 0 到 50 的下标走一遍比盯着代码看十分钟有效。手算这一步听起来笨但它能暴露所有取模错误。5.3 构建与环境类的一个典型现象五同样的代码别人机器上跑得好好的到自己这里一编译就一堆报错或者编译过了但运行结果不对。原因通常是三件事编译器版本和默认标准不同for循环里声明变量在老标准下直接报错源文件编码不同中文字符串在某些终端下显示为乱码甚至导致编译错误结构体对齐方式不同如果代码里有按字节读取结构体的操作跨平台结果会不一样。解决办法是固定编译环境Makefile 里显式写-stdc11源文件统一用 UTF-8 无 BOM 保存涉及二进制读取的地方用固定宽度的整型int32_t这类而不是int。这三条做到了绝大多数“换台机器就跑不了”的问题会消失。另外提醒一句-Werror开着的时候编译器升级导致新增警告会让老代码突然编不过。这种情况不要急着删-Werror先看清楚新警告说什么多半是真的有问题。6. 把实验做成可验证的工程断言、内存检查和耗时对比实验代码能跑出正确结果只是及格线。真正让我少走弯路的两件事一是用断言把边界条件固定住二是用工具把内存问题和性能差异量化出来而不是靠眼睛看。第一件事是给每个核心函数配一组断言式的测试。不用引入任何测试框架一个assert就够#include assert.h #include seqlist.h static void test_seqlist_insert_boundary(void) { SeqList L; assert(SeqList_Init(L) 0); assert(SeqList_Insert(L, 1, 10) 0); /* 空表插第一个合法 */ assert(L.length 1); assert(SeqList_Insert(L, 3, 20) 0); /* 尾部追加pos length1合法 */ assert(SeqList_Insert(L, 5, 30) -3); /* 超出 length1必须失败 */ assert(SeqList_Insert(L, 0, 40) -3); /* 下界越界必须失败 */ assert(L.length 2); /* 两次失败不能改变长度 */ }这类测试的价值在于它把“我以为的边界”变成了“代码承认的边界”。写实验报告时被问到pos的合法范围直接跑一遍就能给出答案不用凭记忆。更实际的好处是改实现的时候跑一遍测试能在十秒内知道有没有改坏东西。不过assert在-DNDEBUG下会被整个去掉所以正式的课程设计里我会把它换成自己的宏失败时打印行号和表达式而不是直接 abort。第二件事是打开地址检查跑一遍。命令就是第 2 章里那个make asan跑完之后看输出里有没有ERROR: AddressSanitizer开头的段落。有的话它会直接告诉你出问题的行号、是越界还是泄漏、分配和释放的调用栈比用 gdb 单步快得多。我一般会在提交实验前固定跑一遍尤其是树和图这两个模块。第三件事是耗时对比。排序算法那一章经常要求“比较不同算法的时间”很多人凭理论复杂度写结论。用clock_gettime实测一下结论会具体很多#include time.h static double now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); /* 单调时钟不受系统时间调整影响 */ return ts.tv_sec * 1000.0 ts.tv_nsec / 1e6; } /* 用法在排序前后各取一次时间相减得到毫秒数 */ double t0 now_ms(); InsertSort(a, n); double t1 now_ms(); printf(InsertSort n%d cost%.3f ms\n, n, t1 - t0);用CLOCK_MONOTONIC而不是CLOCK_REALTIME是因为后者会被系统对时调整测出来的耗时可能是负数。数据规模建议至少取 1000、5000、20000 三个量级规模太小时两个算法都在一毫秒以内测不出差异只测一个规模又看不出增长趋势。三种 O(n²) 排序在小规模上差异不大到两万这个量级插入排序和冒泡排序的差距才会明显拉开。这套流程走下来实验报告里的每一条结论背后都有一次实际运行而不是抄来的复杂度表。代价大概多花半小时但被追问细节时能答得上来。从那以后我每次接手一份新的 C 语言实验代码无论看起来多简单都强制走一遍-Werror编译、地址检查、边界断言这三步再开始读逻辑。绝大多数“玄学问题”其实在第一步就被拦下来了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?