☰
数据结构实验源码合集:C语言从编译跑通到二次开发
2026/11/4 12:53:11 网站建设 项目流程

简介:面向数据结构课程学习者与考研复习者的南邮实验源码合集,覆盖线性表、栈与队列、树与图四次实验的完整实现,可对照课程进度动手验证顺序表与链表操作、表达式求值、二叉树与哈夫曼树构造、图的遍历与最短路径等典型题目。压缩包共64个文件、约1.58MB,以h头文件和cpp源文件为主体,另含dsp与dsw工程文件、doc实验报告和txt说明,以及exe、pdb等编译产物;文件按实验序号分目录组织,可直接在Visual C++中打开调试。目前已有2642人学习下载。源码保留原始工程结构,便于断点跟踪、比对课本伪代码与实际实现的差异,并借助实验报告理解设计思路与测试用例;适合作为课程实验的参考实现与排错对照,但需在理解基础上改写扩展、遵守学术诚信。

1. 解压之后是十几个按实验序号命名的文件夹,这就是数据结构实验源码最真实的样子

压缩包解开,里面没有 README,没有 Makefile,只有一堆按exp01、exp02排下去的文件夹,每个文件夹里躺着一两个.c文件和一个.h,双击打开就是满屏printf加malloc,连编译命令都得自己猜。这份数据结构实验源码合集就是这样一份东西:它按课程进度把线性表、栈与队列、串、树、图、查找、排序几条主线各写了一份参考实现,语言是 C,风格是典型的教学代码——函数名直接、注释不全、结构体裸露在头文件里。

它适合两类人。第一类是在赶实验、需要一份能编译能跑、输出对得上的对照实现的人;第二类是已经把课本伪代码看明白了,但真让自己从零写指针和递归还是会卡住,想拿一份成型的代码反向拆解的人。下面按“先让它跑起来、再拆核心实现、最后讲怎么改”的顺序往下走。

2. 工程结构与编译链路:把一摞 .c 文件变成能跑的可执行程序

拿到这种源码包,第一件事不是读代码,而是先让它编译通过。教学代码最常见的毛病是每个.c都自带一个main,你直接gcc *.c -o test会立刻撞上重复定义的符号。所以第一步永远是先看清文件是怎么组织的。

2.1 目录骨架:按数据结构类型分层,而不是按实验序号

原始包大概率是“一个实验一个文件夹、文件夹里一个完整可编译的小程序”这种形态。这在交作业时没问题,但你想复用里面的链表去写图,就会发现代码全是复制粘贴。我一般会先做一次重排,把“实现”和“驱动”拆开:

ds-lab/ ├── Makefile ├── include/ │ ├── seqlist.h # 顺序表 │ ├── linklist.h # 单链表 │ ├── stack.h # 顺序栈 / 链栈 │ ├── queue.h # 循环队列 │ ├── bitree.h # 二叉树 │ ├── huffman.h # 哈夫曼树 │ ├── graph.h # 邻接矩阵 + 邻接表 │ ├── search.h # 哈希表、折半查找 │ └── sort.h # 六种排序 ├── src/ │ ├── seqlist.c │ ├── linklist.c │ ├── stack.c │ ├── queue.c │ ├── bitree.c │ ├── huffman.c │ ├── graph.c │ ├── hash.c │ └── sort.c ├── tests/ │ ├── test_seqlist.c # 每个文件自带一个 main,只做验证 │ ├── test_bitree.c │ └── ... ├── data/ │ └── graph.txt # 图的顶点边数据,从文件读,避免硬编码 └── bin/

这样拆的好处很直接:src/下每个文件只导出函数、不含main,tests/下每个文件只含一个main并链接对应的.o。你想单独验证链表,就只编linklist.o加test_linklist.o,不会牵连其他模块。头文件里只放结构体定义和函数声明,具体的malloc、free全在.c里,后面要做泛型改造时改动面会小很多。

提示:拆文件时先别动逻辑,原样搬运,搬完编译一遍确认符号都对得上,再开始改代码。一次性重构加改逻辑,出问题你分不清是哪一步引入的。

2.2 Makefile 与编译选项:-Wall -Wextra -g一个都别省

教学代码在老师机器上能跑,在你机器上段错误,八成是编译选项太宽松掩盖了问题。我习惯的编译参数是这样的:

CC := gcc CFLAGS := -std=c99 -Wall -Wextra -g -O0 -Iinclude LDFLAGS := SRCS := $(wildcard src/*.c) OBJS := $(SRCS:src/%.c=build/%.o) TESTS := $(wildcard tests/*.c) BINS := $(TESTS:tests/%.c=bin/%) all: $(BINS) build/%.o: src/%.c @mkdir -p build $(CC) $(CFLAGS) -c $< -o $@ bin/%: tests/%.c $(OBJS) @mkdir -p bin $(CC) $(CFLAGS) $< $(OBJS) -o $@ clean: rm -rf build bin .PHONY: all clean

-std=c99是为了让for (int i = 0; ...)这种写法合法,很多教学代码是 C89 风格,变量全在函数开头声明,混着编译会报错。-Wall -Wextra打开的警告里,最值钱的是uninitialized和unused variable两类——前者直接对应后面要讲的“结果时对时错”,后者常常指向你复制粘贴后忘了改的变量。-g -O0是为了用 gdb 单步,开了优化之后变量会被优化掉,断点跳来跳去,调试体验很差。

链接时有个坑:$(OBJS)里所有.o一起链进去,如果某个.c里有全局变量重名(比如两个文件都定义了int n;),链接阶段就会报 multiple definition。C99 之后正确的写法是在.c里定义、在.h里extern声明,教学代码里经常偷懒不区分。

2.3 三行命令跑通第一个实验:以顺序表为例

环境准备好之后,验证链路是否打通最省事的办法是挑最简单的一个模块跑:

make clean && make # 全量编译,看有没有 warning ./bin/test_seqlist # 跑顺序表测试 valgrind --leak-check=full ./bin/test_seqlist # 有 valgrind 的话顺手查泄漏

第一条命令的输出里,正常情况下应该只有编译行,不该出现任何warning:。如果出现implicit declaration of function 'malloc',说明.c里漏了#include <stdlib.h>,这是教学代码最高频的低级错误之一。第二条命令能跑出预期输出,说明你的目录重排没破坏逻辑。

第三条命令是很多人的盲区。数据结构实验里到处都是malloc,而出错路径上经常直接return -1而没有free,程序跑完看起来一切正常,实际每次调用都漏一小块内存。valgrind 会明确告诉你definitely lost多少字节、在哪一行分配的。没有 valgrind 的环境,用gcc -fsanitize=address,undefined重编一遍也能抓出大部分越界和泄漏。

3. 线性结构拆解:顺序表扩容、链表逆置与栈队列的边界处理

线性表是整个课程序列的地基,后面的树、图、哈希表,本质上都是在这套“结构体 + 指针 + 长度”的写法上做变形。这三个模块的代码量都不大,但边界条件最密集,值得逐行看。

3.1 顺序表:capacity 翻倍扩容与 realloc 的返回值陷阱

教学版顺序表常见的写法是int data[MAXSIZE]加一个length,定长数组,插入超过上限就打印“表满”。这种写法交作业够用,但你一旦想拿它当动态数组用就没法用了。我一般会改成三字段结构:

/* include/seqlist.h */ typedef struct { int *data; /* 动态数组首地址 */ int length; /* 当前元素个数 */ int capacity; /* 当前容量,扩容时翻倍 */ } SeqList; int seqlist_init(SeqList *L, int cap); void seqlist_destroy(SeqList *L); int seqlist_push_back(SeqList *L, int v); int seqlist_insert(SeqList *L, int pos, int v); /* pos 从 0 开始 */ int seqlist_erase(SeqList *L, int pos); int seqlist_find(SeqList *L, int v);

核心在插入时的扩容逻辑:

/* src/seqlist.c */ int seqlist_init(SeqList *L, int cap) { if (cap <= 0) return -1; L->data = (int *)malloc(sizeof(int) * cap); if (L->data == NULL) return -1; L->length = 0; L->capacity = cap; return 0; } int seqlist_push_back(SeqList *L, int v) { if (L->length == L->capacity) { int newcap = L->capacity * 2; /* 注意:realloc 失败返回 NULL,但原指针仍然有效, 必须用临时变量接,否则原来的 data 就丢了 */ int *p = (int *)realloc(L->data, sizeof(int) * newcap); if (p == NULL) return -1; L->data = p; L->capacity = newcap; } L->data[L->length++] = v; return 0; }

这段代码里最值得说的是realloc那一行。realloc的语义是“尝试在原有内存块上扩展,扩不了就另找一块、拷贝、释放旧的”。如果内存不足,它返回NULL并且不会释放原来的块。所以写成L->data = realloc(L->data, ...)一旦失败,L->data被赋成NULL,原来那块内存就彻底漏了,而且后续所有访问都会段错误。这是典型的“看起来能跑,压测才炸”的写法。

参数上,push_back返回 0 表示成功、-1 表示失败,调用方必须判返回值。教学代码里很多直接void返回,出问题你只能靠打印日志猜。erase里删除第pos个元素之后,要把pos+1到length-1的元素整体前移一位,循环边界写错一位就会读到未初始化内存。

3.2 单链表:头插、尾插与一次遍历的原地逆置

链表实验几乎是必考“头插法建表、尾插法建表、就地逆置、有序合并”这四件套。头插和尾插的区别只在一个指针,但输出顺序完全相反,考的就是你有没有搞清楚指针怎么改。

/* include/linklist.h */ typedef struct ListNode { int data; struct ListNode *next; } ListNode; /* 头插法:每读一个数插在头结点之后,最终链表是输入的逆序 */ ListNode *list_create_head(int n) { ListNode *head = (ListNode *)malloc(sizeof(ListNode)); head->next = NULL; /* 带头结点,简化边界处理 */ for (int i = 0; i < n; i++) { ListNode *node = (ListNode *)malloc(sizeof(ListNode)); scanf("%d", &node->data); node->next = head->next; /* 新结点指向原第一个结点 */ head->next = node; /* 头结点指向新结点 */ } return head; } /* 尾插法:维护一个 tail 指针,最终顺序与输入一致 */ ListNode *list_create_tail(int n) { ListNode *head = (ListNode *)malloc(sizeof(ListNode)); ListNode *tail = head; head->next = NULL; for (int i = 0; i < n; i++) { ListNode *node = (ListNode *)malloc(sizeof(ListNode)); scanf("%d", &node->data); node->next = NULL; tail->next = node; tail = node; } return tail == head ? head : head; /* 返回头结点 */ }

带头结点是个关键选择。不带头结点的话,插入和删除第一个位置都要单独判if (pos == 0)改head本身,代码里到处是这种分支,很容易漏。带头结点之后,所有插入删除都变成“改前驱的 next”,逻辑统一,代价是多一次malloc和一点内存。

逆置是链表题的经典套路,三指针法一遍扫完:

void list_reverse(ListNode *head) { ListNode *prev = NULL; ListNode *cur = head->next; while (cur != NULL) { ListNode *next = cur->next; /* 先存后继,否则改完 next 就断了 */ cur->next = prev; /* 当前结点反向指回去 */ prev = cur; /* 两个指针一起前移 */ cur = next; } head->next = prev; /* 头结点指向新的第一个结点 */ }

三行的顺序不能换:先存next,再改cur->next,最后双双前移。如果先改cur->next,next就丢了,后面的链表整段失联——这是链表题最常见的一次性翻车点。

3.3 栈与队列:数组实现里的两组判空判满条件

栈和队列在课程序列里是“受限的线性表”,实现上比链表简单,但边界判断比链表更容易写错。

顺序栈就两个字段,top指向栈顶元素:

#define MAXSIZE 100 typedef struct { int data[MAXSIZE]; int top; /* 空栈时 top == -1 */ } SqStack; int stack_push(SqStack *s, int v) { if (s->top == MAXSIZE - 1) return -1; /* 满 */ s->data[++s->top] = v; return 0; } int stack_pop(SqStack *s, int *out) { if (s->top == -1) return -1; /* 空 */ *out = s->data[s->top--]; return 0; }

循环队列才是真正的坑点。因为front == rear既可能表示空、也可能表示满,教学代码有两种处理:少用一个存储单元,或者额外加一个count字段。用第一种的话,判空和判满的条件必须记死:

#define QMAX 100 typedef struct { int data[QMAX]; int front; /* 指向队头元素 */ int rear; /* 指向队尾元素的下一个位置 */ } SqQueue; int queue_empty(SqQueue *q) { return q->front == q->rear; } int queue_full (SqQueue *q) { return (q->rear + 1) % QMAX == q->front; } int queue_push(SqQueue *q, int v) { if (queue_full(q)) return -1; q->data[q->rear] = v; q->rear = (q->rear + 1) % QMAX; return 0; } int queue_pop(SqQueue *q, int *out) { if (queue_empty(q)) return -1; *out = q->data[q->front]; q->front = (q->front + 1) % QMAX; return 0; }

(q->rear + 1) % QMAX == q->front这个判满条件等价于“队尾再走一步就撞上队头”,代价是永远有一个位置空着,实际可用容量是QMAX - 1。很多人第一次写会写成q->rear == q->front判满,结果队列一开始就是“满”的,一个元素都存不进去。

4. 树、图与排序:从递归建树到六种排序的实测开销

这一章是整个代码包里分量最重的部分,也是从“会写函数”跨到“会分析复杂度”的分水岭。树和图的代码有个共同特点:递归写法短、非递归写法长,但考试和实际项目里两种都会遇到,最好都过一遍。

4.1 二叉树:从先序序列建树到三种遍历

教学里最常见的建树方式是“按先序序列递归建树,空结点用#表示”。输入ABD##E##CF###就能建出一棵完整的树:

/* include/bitree.h */ typedef struct BiTNode { char data; struct BiTNode *lchild, *rchild; } BiTNode; /* 按先序序列建树, idx 是当前读到第几个字符 */ BiTNode *bitree_create(const char *s, int *idx) { char c = s[(*idx)++]; if (c == '#' || c == '\0') return NULL; /* 空子树 */ BiTNode *node = (BiTNode *)malloc(sizeof(BiTNode)); node->data = c; node->lchild = bitree_create(s, idx); /* 递归建左子树 */ node->rchild = bitree_create(s, idx); /* 递归建右子树 */ return node; } void bitree_preorder(BiTNode *t) { /* 先序:根左右 */ if (!t) return; printf("%c ", t->data); bitree_preorder(t->lchild); bitree_preorder(t->rchild); }

idx必须传指针,因为递归调用里要共享同一个读位置。传值的话每层都是副本,序列会被重复读取,最终建出来的树结构完全对不上——这是递归建树最隐蔽的错误。

非递归中序是考试重点,思路是“一路向左压栈,弹出访问,再转向右子树”:

void bitree_inorder_iter(BiTNode *t) { BiTNode *stack[128]; int top = -1; BiTNode *p = t; while (p != NULL || top >= 0) { while (p != NULL) { /* 一路向左,沿途入栈 */ stack[++top] = p; p = p->lchild; } p = stack[top--]; /* 弹出栈顶,就是当前最左未访问结点 */ printf("%c ", p->data); p = p->rchild; /* 转向右子树 */ } }

固定大小的stack[128]是教学代码的常见偷懒写法,树高超过 128 就崩。自己用的话建议改成malloc一个sizeof(BiTNode*) * (树高 + 1)的动态栈。

4.2 哈夫曼树:静态数组选两次最小值的写法

哈夫曼树的代码不长,但有个约定必须记住:n个叶子结点建出来的哈夫曼树一共2n-1个结点,数组要开到2n。合并时每次从“还没被合并过的结点”里挑两个权值最小的:

typedef struct { int weight; int parent, lchild, rchild; /* 下标,-1 表示无 */ } HTNode, *HuffmanTree; void huffman_select(HuffmanTree ht, int n, int *s1, int *s2) { int min1 = INT_MAX, min2 = INT_MAX; *s1 = *s2 = -1; for (int i = 0; i < n; i++) { if (ht[i].parent != -1) continue; /* 已合并过,跳过 */ if (ht[i].weight < min1) { min2 = min1; *s2 = *s1; min1 = ht[i].weight; *s1 = i; } else if (ht[i].weight < min2) { min2 = ht[i].weight; *s2 = i; } } }

if (ht[i].parent != -1) continue;这一行是死循环的源头。如果漏了它,已经合并过的结点会被反复选中,s1 == s2,然后你拿一个结点跟自己合并,parent指来指去,循环永远退不出去。哈夫曼编码部分是从叶子往根回溯,左 0 右 1,回溯出来的码是逆序的,记得倒一下再存。

4.3 图:邻接矩阵与邻接表的取舍

图的两种存储结构都必须会写,因为它们的适用场景完全不同:

存储方式空间复杂度判断两点是否相邻遍历某点所有邻接点适合场景
邻接矩阵O(n²)O(1)O(n)稠密图、需要频繁判边
邻接表O(n+e)O(出度)O(出度)稀疏图、遍历为主

邻接矩阵就是一个二维数组加顶点表,代码极短:

#define MAXV 100 typedef struct { char vexs[MAXV]; /* 顶点表 */ int edges[MAXV][MAXV]; /* 边表,无边用 0 或 INF */ int n, e; /* 顶点数、边数 */ } MGraph; void mgraph_init(MGraph *g, int n) { g->n = n; g->e = 0; for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) g->edges[i][j] = (i == j) ? 0 : INT_MAX; /* 自身 0,无边 INF */ }

邻接表的结点结构是典型的“数组 + 链表”:

typedef struct ArcNode { /* 边结点 */ int adjvex; /* 邻接点在顶点表中的下标 */ int weight; /* 权值,无权图可省 */ struct ArcNode *next; } ArcNode; typedef struct VNode { /* 顶点结点 */ char data; ArcNode *first; /* 指向第一条出边 */ } VNode; typedef struct { VNode adjlist[MAXV]; int n, e; } ALGraph;

用邻接矩阵求最短路径方便,用邻接表跑 DFS/BFS 快。教学代码里经常两种都写一遍,然后用同一组数据互相验证——这是个好习惯,你可以在data/graph.txt里存一份边集,两个实现读同一份文件,输出必须一致。

4.4 DFS/BFS 与 Dijkstra:一个 visited 数组贯穿到底

DFS 递归写三行,BFS 用队列,Dijkstra 本质上是 BFS 的加权版,三者共享一个visited数组:

int visited[MAXV]; void dfs(ALGraph *g, int v) { visited[v] = 1; printf("%c ", g->adjlist[v].data); for (ArcNode *p = g->adjlist[v].first; p; p = p->next) if (!visited[p->adjvex]) dfs(g, p->adjvex); } void dijkstra(MGraph *g, int src, int dist[]) { int done[MAXV] = {0}; for (int i = 0; i < g->n; i++) dist[i] = g->edges[src][i]; dist[src] = 0; done[src] = 1; for (int k = 1; k < g->n; k++) { int u = -1, min = INT_MAX; for (int i = 0; i < g->n; i++) /* 选未确定点里 dist 最小的 */ if (!done[i] && dist[i] < min) { min = dist[i]; u = i; } if (u == -1) break; /* 剩余点不可达 */ done[u] = 1; for (int i = 0; i < g->n; i++) /* 用 u 松弛邻居 */ if (!done[i] && g->edges[u][i] != INT_MAX && dist[u] + g->edges[u][i] < dist[i]) dist[i] = dist[u] + g->edges[u][i]; } }

visited数组每次遍历前必须清零,否则第二次调用直接从空输出开始。dijkstra里那个if (u == -1) break;也别省,图不连通的时候不放它,u会一直是 -1,下一轮拿dist[-1]去做松弛,直接越界崩溃。

4.5 哈希表与六种排序:统一接口才测得出来差距

哈希表最常考的冲突处理是线性探测,配合除留余数法:

#define HSIZE 13 typedef struct { int key; int used; } HashSlot; int hash_insert(HashSlot *t, int key) { int h = key % HSIZE; for (int i = 0; i < HSIZE; i++) { int idx = (h + i) % HSIZE; /* 线性探测:冲突就往后挪一格 */ if (!t[idx].used) { t[idx].key = key; t[idx].used = 1; return idx; } if (t[idx].key == key) return idx; /* 已存在,不重复插 */ } return -1; /* 表满 */ }

HSIZE一般取质数,取 2 的幂会让key % HSIZE只用到低位,冲突率明显上升。这不是玄学,是取模运算的性质决定的。

排序部分,六种算法如果各自写一个void sort(int a[], int n),你没法用一个循环批量测。用函数指针统一接口就方便多了:

typedef void (*SortFn)(int *, int); static void test_one(const char *name, SortFn fn, const int *base, int n) { int *a = (int *)malloc(sizeof(int) * n); memcpy(a, base, sizeof(int) * n); clock_t t0 = clock(); fn(a, n); clock_t t1 = clock(); printf("%-10s %8.2f ms\n", name, (double)(t1 - t0) * 1000.0 / CLOCKS_PER_SEC); free(a); } int main(void) { int n = 50000; int *base = (int *)malloc(sizeof(int) * n); srand(42); for (int i = 0; i < n; i++) base[i] = rand() % 100000; test_one("insert", insertion_sort, base, n); test_one("bubble", bubble_sort, base, n); test_one("select", selection_sort, base, n); test_one("quick", quick_sort, base, n); test_one("merge", merge_sort, base, n); test_one("heap", heap_sort, base, n); free(base); return 0; }

memcpy那一步不能省:排序会原地改数组,如果六个算法共用同一块内存,第一个跑完后面的就全在排有序数组了,快排退化成 O(n²),测出来的数字毫无意义。每次malloc一份副本是最省心的做法,五万个数也就两百 KB。

5. 常见问题排查:五个让人半夜爬起来改代码的坑

这些实验代码单独跑的时候大概率没事,一旦换了输入规模、换了机器、或者被别的模块调用,问题就集中爆发。下面五条是我自己踩过、也在别人代码里反复见到的。

5.1 段错误:九成是空指针和数组越界

现象:程序编译零警告,运行到某个函数直接Segmentation fault,gdb 打出来栈是乱的。

原因:三种最常见——malloc后没判NULL直接用;循环边界写成i <= length,读了data[length]这个未初始化位置;链表遍历时p = p->next写在if (!p)判断之前。

解决:先用gcc -fsanitize=address -g重编,ASan 会直接指出越界发生在哪一行、越界多少字节,比 gdb 单步快得多。链表类的代码,养成“解引用前一定先判空”的习惯,尤其是while (p->next)和while (p)的区别要想清楚。

5.2 结果时对时错:malloc出来的内存没清零

现象:同一个程序跑十次,哈希表查找有时候能找到、有时候找不到;图的 BFS 偶尔多输出一个顶点。

原因:malloc只分配不初始化,拿到的内存里是上次用过的残留数据。如果你的visited数组、used标记位定义成局部变量又忘了逐个清零,行为就完全依赖栈上残留什么,跑一次一个样。

解决:结构体数组统一用calloc(n, sizeof(T)),或者memset(arr, 0, sizeof(arr))。注意memset的第三个参数要传sizeof(数组)而不是sizeof(指针)——数组作为函数参数退化成指针之后,sizeof只会得到 8,这是个非常典型的隐形错误。

5.3 中文乱码与文件打不开:编码和工作目录

现象:代码里写了中文注释和printf("请输入顶点数:"),在 A 机器上正常,拷到 B 机器上输出全是问号;读data/graph.txt时报fopen返回NULL。

原因:编码上有 GBK 和 UTF-8 的差异,源文件是什么编码,终端就得是什么编码。路径上,fopen("data/graph.txt", "r")是相对路径,相对于进程当前工作目录,不是相对于可执行文件所在目录。你在项目根目录./bin/test_graph能跑,cd bin && ./test_graph就找不到文件。

解决:源文件统一存成 UTF-8,编译和终端也统一成 UTF-8。读文件时如果对路径没把握,就在fopen失败的分支里打印strerror(errno)和当前路径(getcwd),一眼就能看出跑到哪个目录去了。或者干脆把数据文件路径做成命令行参数传进去。

5.4 内存泄漏:提前return吃掉了free

现象:功能完全正确,但 valgrind 报definitely lost,跑久了内存占用一路上涨。

原因:函数中途出错直接return -1,后面的free全被跳过;或者链表删除结点时只改了指针,忘了free被摘下来的那个;或者realloc失败后原指针丢失。

解决:把“分配—使用—释放”写成单出口,用goto cleanup收敛释放逻辑:

int load_graph(const char *path, ALGraph *g) { int ret = -1; FILE *fp = fopen(path, "r"); if (!fp) goto cleanup; if (parse(fp, g) != 0) goto cleanup; ret = 0; cleanup: if (fp) fclose(fp); return ret; }

C 里goto用在错误处理上是合理的,Linux 内核里到处都是这种写法,别被“goto 有害论”绑住。

5.5 死循环与递归栈溢出:边界条件写反了

现象:哈夫曼树构造卡住不动,或者二叉树遍历跑着跑着崩了,gdb 栈里几百层bitree_create。

原因:哈夫曼里的ht[i].parent != -1漏写,已合并结点被反复选中;递归建树时终止条件写成c == '#'但输入序列结尾没带#,idx越界读到随机字符,递归永远不返回。

解决:递归函数一律先写终止条件再写递归体,并且保证“每次递归都让问题规模严格变小”。建树这类按字符串读的,除了判'#'还要判'\0'。机器上跑之前,先拿一棵只有三四个结点的树手工推一遍,比崩了之后再 gdb 划算得多。

6. 二次开发:把实验代码改成能进项目的组件

实验代码和工程代码之间隔着的不是算法,而是接口和验证。我一般从三个方向改:泛型化、加断言、加工具链。

第一件事是把int换成通用类型。所有链表、顺序表都写死int data,实际项目里要存的是结构体指针、字符串、甚至另一个容器。最小改动是加void *加元素大小,稍微正规一点是学 C++ 模板那套思路,用宏生成类型特化版本:

/* 用宏生成指定类型的顺序表,避免手写 N 份几乎一样的代码 */ #define DEFINE_LIST(TYPE, NAME) \ typedef struct { TYPE *data; int len, cap; } NAME; \ static int NAME##_push(NAME *l, TYPE v) { \ if (l->len == l->cap) { \ int nc = l->cap ? l->cap * 2 : 8; \ TYPE *p = (TYPE *)realloc(l->data, sizeof(TYPE) * nc); \ if (!p) return -1; \ l->data = p; l->cap = nc; \ } \ l->data[l->len++] = v; \ return 0; \ } DEFINE_LIST(int, IntList) DEFINE_LIST(double, DoubleList)

宏展开后每个类型一份独立的函数,类型安全、没有强制转换,代价是代码体积。这种写法在 C 项目里很常见,比自己手写两份.c靠谱。

第二件事是加断言。实验代码的验证基本靠“肉眼看输出”,这在只有两个用例时够用,改完之后就不可靠了。把每个测试文件里的printf换成assert,失败时直接告诉你哪一行期望什么、实际是什么:

#include <assert.h> int main(void) { SeqList L; assert(seqlist_init(&L, 2) == 0); assert(seqlist_push_back(&L, 10) == 0); assert(seqlist_push_back(&L, 20) == 0); assert(seqlist_push_back(&L, 30) == 0); /* 触发扩容 */ assert(L.length == 3 && L.capacity == 4); assert(seqlist_find(&L, 20) == 1); assert(seqlist_erase(&L, 0) == 0); assert(L.length == 2 && L.data[0] == 20); seqlist_destroy(&L); return 0; }

配上assert之后,改一次实现跑一次make && ./bin/test_seqlist,失败立刻退出,不用盯着屏幕对输出。

第三件事是把工具链挂上去。写个make check目标,一键跑全部测试并开 ASan:

# 开着地址消毒器跑全量测试 make clean make CFLAGS="-std=c99 -Wall -Wextra -g -O0 -Iinclude -fsanitize=address,undefined" for f in bin/test_*; do echo "== $f"; "$f" || echo "FAILED: $f" done

再往上一层是“对拍”:排序算法写六个,用qsort的结果当标准答案,随机生成几千组数据,每组都比对一遍;图的最短路用邻接矩阵版和邻接表版跑同一份随机图,输出必须逐字节相同。对拍能抓出你手写快排里边界差一位的那种错误,这种错误靠肉眼是发现不了的。

有一个血泪教训我记到现在:某次把实验里的快排直接拷进项目当工具函数用,自测过了,上线之后发现数据量大且已基本有序时会退化得极慢,原因是教科书上的快排固定取第一个元素当枢轴。从那以后我每次从这种源码包里往外搬代码,都强制先跑一遍对拍和规模梯度压测,再确认一遍边界条件,然后才允许它进项目。这份数据结构实验源码合集的价值不在于代码本身能直接用,而在于它是一份够干净、够完整的起点——把它跑通、读懂、改成你自己的东西,比从头闷着头写要省太多时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询