简介:严蔚敏《数据结构与算法C语言》教材的配套代码实现合集,面向计算机专业学生、考研复习者及需要提升算法功底的一线程序员。资源按教材章节体系组织,涵盖线性表、栈与队列、树与二叉树、图、排序与查找、动态规划、贪心算法、回溯法等核心知识点的可运行源码,每种数据结构均提供C与C++双版本实现,配有头文件与简单测试数据,便于对照书本理论逐行理解。压缩包共416个文件,以154个cpp、154个c源码及89个h头文件为主体,附带少量txt说明与dat测试数据,整体仅494KB,体积轻量、结构清晰,适合离线收藏与反复研读。已有1607人学习下载,是严蔚敏经典教材的高频配套资源之一。拿到手即可编译运行,边调试边体会栈、二叉树、最短路径等经典算法的实现细节,将晦涩的理论真正转化为动手与应试能力。
1. 严蔚敏《数据结构(C语言版)》的代码实现:抄完书上的算法,为什么还是跑不出结果
严蔚敏《数据结构(C语言版)》是很多计算机专业学生第一本翻到卷边的教材,但真正动手写代码时会发现一个尴尬局面:书上的算法描述全都是类C风格的,缺类型定义、缺头文件、甚至函数参数里还写着“&L”这种C语言不支持的引用写法。直接照着抄,编译就报错。这不是你基础差,而是这本书本来就不负责给你一份能直接运行的工程代码,你需要自己把“算法长什么样”翻译成“编译器能接受什么”。这篇文章就从顺序表开始,一路覆盖链表、二叉树、KMP、堆排序这些高频模块,讲清楚每个模块怎么改造成可编译、可运行的C代码,顺手解决实验报告怎么写、期末和408复习怎么抓重点的问题。目标读者是准备数据结构实验、期末复习和考研的人,新手能照着写,熟手能避开常见坑。
2. 从类C到可运行的C:严蔚敏代码落地的第一步是改掉这几个习惯
2.1 看懂Status、ElemType和函数头:动手前先花十分钟做翻译
严蔚敏书里的算法描述大量使用 Status、ElemType 这类抽象类型标识。在C语言里,这些都是“不存在的”,需要你提前通过 typedef 定义成真实类型。很多第一次上手的同学没意识到这一点,照着抄完发现满屏“Status undefined”,立刻心态崩了。其实这不是你的问题,而是书的描述语言和你使用的编译器语言存在一层翻译工作。
常见做法是先建一个公共头文件,把这些记号统一翻译好:
/* common.h —— 严蔚敏代码的公共类型定义 */ #include <stdio.h> #include <stdlib.h> #include <string.h> typedef int Status; /* 函数执行状态,用 OK/ERROR 表示成功失败 */ typedef int ElemType; /* 表里存的数据类型,需要时改成 float、struct 等 */ #define TRUE 1 #define FALSE 0 #define OK 1 #define ERROR 0 #define INFEASIBLE -1 #define OVERFLOW -2这段代码的核心是给“类型”套一层别名。Status 本质上就是 int,返回值用宏定义做语义区分;ElemType 更关键,因为它决定了顺序表、链表的数据单元是什么。当你把一个存 int 的表改成存自定义结构体时,只需要改这一行 typedef,所有用到 ElemType 的地方自动跟着变。这种写法不是多余的,它在告诉你:数据结构代码应该和具体数据类型解耦。
真正让很多人在编译期就卡住的,是函数参数里的“引用”。书里写 ListInsert(&L, i, e),在纯 C 环境下没有引用类型,常见做法是把参数改成指针,例如 ListInsert(SqList *L, int i, ElemType e)。如果你在某份参考代码里看到形参写的是 SqList *L,调用处传的是 &L,那说明作者已经把类C语法翻译成指针语义了。反过来,如果函数定义里收的是 SqList L,调用处也传了 L,那你对结构体做的所有修改在函数返回后都会被丢弃。这是 2.3 要展开说的坑。
2.2 顺序表最小可运行版本:从SqList结构体到插入函数一次过
顺序表是全书第一个正式的数据结构,也是几乎所有实验的基础。它的实现思路不复杂:一块连续数组加一个 length 记录当前元素个数。难点在于插入和删除时元素移动的方向,以及逻辑位序和数组下标的换算。
先写一版最小但能编译的顺序表:
/* sqlist.c —— 顺序表初始化与插入 */ #define MAXSIZE 100 typedef struct { ElemType data[MAXSIZE]; int length; /* 当前元素个数 */ } SqList; /* 初始化:只保留空表状态,方便重复测试 */ Status InitList(SqList *L) { if (NULL == L) return ERROR; L->length = 0; return OK; } /* 插入:i 从 1 开始,表示逻辑位置第 i 个元素 */ Status ListInsert(SqList *L, int i, ElemType e) { int k; if (i < 1 || i > L->length + 1) return ERROR; /* 越界检查 */ if (L->length >= MAXSIZE) return ERROR; /* 容量检查 */ for (k = L->length; k >= i; k--) { L->data[k] = L->data[k - 1]; /* 从后往前挪,避免覆盖 */ } L->data[i - 1] = e; L->length++; return OK; }逻辑说明:插入位置 i 按书上的习惯从 1 开始,但 C 数组下标从 0 开始,所以真正写入的位置是 data[i-1]。移动元素时一定从最后一个元素开始往前搬,因为如果从前往后,后面的元素还没搬走就被前面的覆盖了。参数 i 的范围判断是 i >= 1 且 i <= length+1,这个 length+1 留了口子给“插到表尾”的场景,不少第一次写的人会把上界写成 length,导致只能在已有元素中间插入。
为了能直接跑起来,再加一个打印函数和 main:
void PrintList(SqList *L) { int i; for (i = 0; i < L->length; i++) printf("%d ", L->data[i]); printf("\n"); } int main(void) { SqList L; int arr[] = {10, 20, 30}; int i; InitList(&L); for (i = 0; i < 3; i++) ListInsert(&L, i + 1, arr[i]); PrintList(&L); return 0; }这段代码里最容易忽略的是 InitList(&L) 里的 &。SqList L 是一个结构体变量,传给 InitList 时必须取地址,函数内部才能通过 L->length 修改调用方的结构体。如果你写成 InitList(L),编译器会报类型错误;就算编译过了,修改也是白做。数组 arr 里的元素是从 1 号位置开始依次插入的,所以循环里传入的位置是 i+1,插入之后表内顺序是 10 20 30,不是 30 20 10,这是顺序表区别于头插链表的地方。
2.3 函数传参改指针:为什么“函数运行完没有变化”是第一个翻车点
很多初学者写完上面代码,遇到的现象是:main 里调用 InitList(L),函数内部 length 确实归零了,但回到 main 一打印,L.length 还是原来的值。原因很简单:C 语言默认值传递,函数拿到的是 L 的一份拷贝,你在函数内部改的是这份拷贝,原结构体没动。
看一个最经典的对比:
/* 错误示范:形参是普通变量,交换只发生在函数内部 */ void bad_swap(ElemType a, ElemType b) { ElemType t = a; a = b; b = t; } /* 正确示范:形参是指针,通过解引用修改调用方的变量 */ void good_swap(ElemType *a, ElemType *b) { ElemType t = *a; *a = *b; *b = t; }bad_swap 不是算法写错了,而是参数传递方式选错了。函数里 a 和 b 的交换确实发生了,但这两个变量是 main 调用时把值拷贝进来生成的“临时替身”,交换完就销毁,对 main 里的原始变量毫无影响。good_swap 传的是地址,函数内部通过 *a 和 *b 直接读写调用方的内存,修改才会被保留。
所以拿到严蔚敏书上任何一个函数,先看它要不要对外部数据做修改。要修改就传指针,不要修改可以按值传。这个习惯培养起来之后,链表、二叉树、图的代码会少踩很多坑,因为你写每个函数时都会先问自己一句:这个参数是来读的还是来写的?
3. 四个高频模块的代码实现:链表、KMP、二叉树与排序算法选讲
3.1 单链表的头插、尾插与遍历:带头结点的写法为什么最省心
链表在实验报告里出镜率极高,尤其是“头插法建表”和“尾插法建表”这种基础操作。严蔚敏书里用的是带头结点的设计,头结点不存数据,只用来统一空表和非空表的处理逻辑。没有头结点时,往空表插入第一个节点要改头指针本身,往非空表插入要改的是某个节点的 next,两种场景得写两套判断。有了头结点后,插入逻辑统一成“在某个节点后面挂新节点”,代码量少一大截。
/* linkedlist.c —— 带头结点的单链表 */ typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; /* 头插法:新节点永远插在头结点之后,最终结果是逆序 */ LinkList CreateList_Head(ElemType a[], int n) { LinkList head = (LinkList)malloc(sizeof(LNode)); int i; head->next = NULL; for (i = 0; i < n; i++) { LinkList p = (LinkList)malloc(sizeof(LNode)); p->data = a[i]; p->next = head->next; /* 新节点指向原来第一个节点 */ head->next = p; /* 头结点指向新节点 */ } return head; } /* 尾插法:维护一个尾指针,每次把新节点挂到尾部,顺序保持 */ LinkList CreateList_Tail(ElemType a[], int n) { LinkList head = (LinkList)malloc(sizeof(LNode)); LinkList tail = head; int i; head->next = NULL; for (i = 0; i < n; i++) { LinkList p = (LinkList)malloc(sizeof(LNode)); p->data = a[i]; p->next = NULL; tail->next = p; tail = p; } return head; }头插法代码里的顺序很重要:先让 p->next 指向 head->next,再把 head->next 指向 p。如果先把 head->next 赋给 p,p 还没挂到链表上,原来的下一个节点就丢了。尾插法多维护一个 tail,tail 始终指向最后一个节点,这样每次插入不需要从头遍历到尾部,建表复杂度是 O(n)。验证是否成功,写一个遍历函数把每个节点的 data 打出来,头插输入 1 2 3 输出 3 2 1,尾插输出 1 2 3,两者一对比就理解了。
注意 malloc 出来的每个节点,next 一定要先赋值。头插法的 p->next 被赋为 head->next,尾插法的 p->next 赋为 NULL。节点数据本身没多大风险,风险全在 next 指针上,一个未初始化的指针会让遍历函数在某个瞬间跳到非法地址,程序崩溃时你根本查不到是哪一步出了问题。
3.2 KMP算法:next数组到底怎么算,先弄懂前缀和后缀
KMP 是数据结构面试和考研里反复出现的考点。它的核心思想是:主串指针不回溯,失配时模式串根据 next 数组跳到下一个可能匹配的位置。很多同学记住了算法流程,却栽在 next 数组的计算上。next[j] 的含义是:当模式串第 j 位失配时,下一个用模式串第几位去和当前主串字符比较。这个值等于模式串 [0, j-1] 这个子串的“最长相等前后缀长度”。
/* kmp.c —— KMP 字符串匹配,下标从 0 开始 */ void get_next(const char *p, int next[]) { int len = (int)strlen(p); int i = 0, j = -1; next[0] = -1; while (i < len - 1) { if (j == -1 || p[i] == p[j]) { i++; j++; next[i] = j; } else { j = next[j]; /* 匹配失败,前缀指针回退 */ } } } int kmp_index(const char *s, const char *p) { int i = 0, j = 0; int slen = (int)strlen(s); int plen = (int)strlen(p); int next[256]; get_next(p, next); while (i < slen && j < plen) { if (j == -1 || s[i] == p[j]) { i++; j++; } else { j = next[j]; } } if (j == plen) return i - j; /* 返回模式串在主串中的起始下标 */ return -1; }这个版本使用 0 下标,和严蔚敏书里“串从 1 号下标开始存、next[1]=0”的描述略有差异,但核心思想一致。你在对比书上代码时,要理解两种实现只是下标平移了一层,不是算法不同。求 next 的过程本质上是用模式串自己和自己做匹配:i 在“主串”位置,j 在“模式串”位置,相等就都前进,不相等就让 j 回退到 next[j],和 KMP 主匹配流程是同构的。
写 KMP 时最典型的错误是混淆长度和下标。比如模式串长度是 plen,最后一个字符的下标是 plen-1,循环条件写成 i < len 就会越界访问 next[len];主串匹配成功判断条件是 j == plen 而不是 j == plen-1,因为循环退出有两种可能,一种是 j 走到头表示匹配完成,一种是 i 走到头表示主串扫完。用“ababc”做模式串,手算一遍 next 数组会让记忆深得多,算出来的结果是 [-1, 0, 0, 1, 2],这个结果可以直接对到 get_next 的代码里验证。
3.3 二叉树:递归遍历一步到位,中序非递归重点检查出栈时机
二叉树遍历是数据结构实验里的固定项目。递归版本代码极短,难的是非递归版本,尤其是中序遍历。中序非递归的思路是:从根开始一路往左走,沿途节点全部入栈;走到没有左孩子时,出栈一个节点并访问,然后转向它的右孩子,再重复“一路往左入栈”的过程。
/* btree.c —— 二叉树定义与中序遍历 */ typedef struct BiTNode { ElemType data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; /* 递归中序遍历 */ void InOrder(BiTree t) { if (t == NULL) return; InOrder(t->lchild); printf("%d ", t->data); InOrder(t->rchild); } /* 非递归中序遍历:栈数组保存节点 */ void InOrder_Stack(BiTree t) { BiTree stack[1024]; int top = 0; while (top > 0 || t != NULL) { while (t != NULL) { stack[top++] = t; /* 左路节点全部入栈 */ t = t->lchild; } if (top > 0) { t = stack[--top]; printf("%d ", t->data); /* 出栈访问 = 中序 */ t = t->rchild; /* 转右子树 */ } } }递归版本的关键是“先判空再递归”,少了这个 if,传入空树时函数会继续访问 t->lchild,对空指针解引用直接段错误。非递归版本的关键在于访问节点的时机:中序要求在左子树处理完之后访问根,所以外层循环每次从栈里弹出那个“左子树已经走完”的节点。栈数组固定 1024,对深度不超过 1024 的二叉树够用,但如果测试数据是一棵退化到 2000 层的单链树,这种固定栈就会溢出。实验报告里可以注明“栈容量可改为动态扩展”,这是一个很好的加分点。
3.4 堆排序与双端队列:算法题和实验报告里的一组常客
排序算法是很多学校实验报告的必选,堆排序在其中格外有代表性,因为它同时考察数组操作和完全二叉树的理解。堆排序分两步:先建堆,再反复把堆顶和最后一个元素交换并调整。
/* sort.c —— 堆排序 */ void sift(int a[], int low, int high) { int i = low, j = i * 2 + 1; /* j 是左孩子 */ int tmp = a[i]; while (j <= high) { if (j + 1 <= high && a[j] < a[j + 1]) j++; /* 选出左右孩子中较大的 */ if (tmp < a[j]) { a[i] = a[j]; i = j; j = i * 2 + 1; } else { break; } } a[i] = tmp; } void heap_sort(int a[], int n) { int k; for (k = n / 2 - 1; k >= 0; k--) /* 从最后一个非叶子开始 */ sift(a, k, n - 1); for (k = n - 1; k > 0; k--) { int t = a[0]; a[0] = a[k]; a[k] = t; sift(a, 0, k - 1); /* 缩小范围重新调整 */ } }建堆为什么从 n/2-1 开始?因为完全二叉树中下标 n/2-1 是最后一个非叶子节点,比它大的下标全是叶子,叶子本身已经满足堆性质,不需要调整。排序阶段每次把最大的堆顶换到尾部,然后缩小堆的范围,重新让堆顶下沉。堆排序的平均时间复杂度是 O(n log n),不稳定,这一点在考研题里经常被问到。
双端队列是“数据结构 双端队列”搜索里的高频词。它的实现通常有两种:双向链表和环形数组。链表实现直观但代码长,环形数组节省空间。下面是一版环形数组的双端队列,牺牲一个数组槽位来区分队空和队满:
/* deque.c —— 环形数组双端队列 */ #define QUEUE_MAX 8 typedef struct { ElemType data[QUEUE_MAX]; int head, tail; /* head 指向队首元素,tail 指向队尾的下一个位置 */ } Deque; void init_deque(Deque *q) { q->head = 0; q->tail = 0; } /* 从队头插入 */ int push_front(Deque *q, ElemType e) { if ((q->tail + 1) % QUEUE_MAX == q->head) return ERROR; /* 队满 */ q->head = (q->head - 1 + QUEUE_MAX) % QUEUE_MAX; q->data[q->head] = e; return OK; } /* 从队尾弹出 */ int pop_back(Deque *q, ElemType *e) { if (q->head == q->tail) return ERROR; /* 队空 */ q->tail = (q->tail - 1 + QUEUE_MAX) % QUEUE_MAX; *e = q->data[q->tail]; return OK; }环形数组的重点是取模运算。head 往前移动时要加 QUEUE_MAX 再取模,因为 head-1 可能变成负数,C 语言的负数取模结果不是我们期望的正数。队列判空用 head == tail,判满用 (tail+1) % MAX == head,相当于约定数组里永远空一个位置。如果你看到别人用 head == tail 既判空又判满,那说明他用了额外变量记录元素个数,两种方案各有利弊,但“用一个空槽位”是初学者最容易理解、最不容易写错的方案。
4. 实验报告与复习的两条路线:一边能交差一边能应付考试
4.1 数据结构实验报告怎么写才不容易被打回:四个板块讲清从题目到代码的完整链路
很多人觉得实验报告就是把代码一贴、截个图就完事。但大多数课程对报告格式有要求,尤其数据结构这种核心课,老师会看你的结构体设计、算法描述和测试用例。常见的高分报告结构是四个板块:需求分析、设计说明、运行结果、调试心得。需求分析要写明“输入是什么、输出是什么、边界条件有哪些”,而不是复述题目原话。设计说明里放关键数据结构定义和核心函数思路,比如顺序表这块,就要说明为什么 insert 的位置从 1 开始、为什么移动元素从后往前。运行结果要贴真实输出,不能只贴代码。调试心得写自己踩过的坑,比如“一开始传参忘记用指针,导致初始化无效”,这类记录老师非常认可。
表格里可以这样规划:
| 报告板块 | 写什么 | 常见错误 |
|---|---|---|
| 需求分析 | 输入范围、输出格式 | 直接抄题目 |
| 设计说明 | 结构体定义、算法时间复杂度 | 只有代码没有解释 |
| 运行结果 | 正确的截图和输出 | 只贴代码不贴输出 |
| 调试心得 | 遇到的问题与解决过程 | 写“没有问题” |
这一栏背后是让学生真正跑过代码,而不是把网上的代码抄一遍。实验报告的价值不在形式,而在于它逼着你把“能跑的代码”和“能说清的思路”对应起来。
4.2 从“5*5鞍点”看暴力枚举与剪枝:报告里的优化痕迹怎么写
有一套常见题是这样:输入一个 5x5 矩阵,找出所有“鞍点”——该位置在它所在行是最大值,在它所在列是最小值。很多人第一反应是暴力枚举所有位置,对每个位置再扫描行和列,这种写法能过,但对大规模矩阵性能很差。用 C 语言实现时,可以先写一版最直接的做法,再优化成一个可复测的版本。
/* saddle.c —— 计算 5*5 矩阵中的鞍点 */ #include <stdio.h> #include <limits.h> int main(void) { int a[5][5]; int i, j, k; int found = 0; for (i = 0; i < 5; i++) for (j = 0; j < 5; j++) scanf("%d", &a[i][j]); for (i = 0; i < 5 && !found; i++) { for (j = 0; j < 5; j++) { int row_max = 1, col_min = 1; for (k = 0; k < 5; k++) { if (a[i][k] > a[i][j]) row_max = 0; if (a[k][j] < a[i][j]) col_min = 0; } if (row_max && col_min) { printf("鞍点: a[%d][%d]=%d\n", i, j, a[i][j]); found = 1; } } } if (!found) printf("未找到鞍点\n"); return 0; }暴力枚举的思路很清晰:每一个位置都做一次“行扫描 + 列扫描”,时间复杂度 O(n^3)。进一步优化,可以先用两个数组 row_max[i] 和 col_min[j] 预存每行最大值和每列最小值,然后再一次两层循环判断多个条件,复杂度降到 O(n^2)。这类“先预处理再主查找”的思路,其实就是算法设计里的空间换时间。
剪枝算法在这个问题里表现为“提前结束不可能的分支”。比如发现某一行已经有两个位置同时竞争最大值,或者某一列已经出现更小值,就不用继续枚举。在更复杂的搜索场景里,剪枝是暴力枚举法最实用的优化手段。实验报告里把暴力版和优化版都写上,再对比时间复杂度,比单纯贴一个最终代码更有说服力。
4.3 期末和408复习视角:图和数组为什么总被单独拎出来考
数据结构期末和408统考里,图和数组是两道分值不低的硬骨头。数组这块考的是“存储地址计算”和“特殊矩阵压缩”——比如二维数组按行优先存储时,a[i][j] 的地址怎么算;对称矩阵、三角矩阵怎么压缩成一维数组。图这边考的是邻接矩阵和邻接表建图、DFS/BFS 遍历顺序、最小生成树、最短路径。这些东西最大的特点是“代码不好写,但计算题爱考”。
复习时建议两条腿走路:先手画图,再手写代码。画图题能训练“从邻接表还原图结构”的直觉,代码题能训练“把结构转换成指针和数组”的能力。408真题里经常给一个图的邻接表,让你写出从某个顶点出发的深度优先遍历序列,这种题如果你不熟悉“遍历时访问哪些邻接点、标记数组怎么用”,很容易丢分。
数组地址计算的通用方法是:按行优先时,地址 = 起始地址 + (i * 列数 + j) * 元素大小。关键是 i 和 j 从 0 开始还是从 1 开始,题目里经常埋坑。图的 DFS 代码遵循“访问后立即标记”的原则,防止重复访问;递归版本要注意系统栈可能不够深,考试时通常会先问“画出递归过程”而不是要求你现场调栈。
5. 避坑专项:把严蔚敏书的算法搬到C语言,这五个坎最多人踩
5.1 顺序表从书上搬到C,第一个翻车点是数组下标
现象是:照着书上代码把插入函数写出来,循环从 1 到 length,结果数据总是错位。原因是:严蔚敏书里顺序表的位序默认从 1 开始,而 C 数组下标从 0 开始,书上的 L.elem[i] 对应 C 里的 L.data[i-1]。如果直接把 i 当数组下标,插入位置和实际存储位置永远差一位。
解决方法是明确区分“逻辑位序”和“物理下标”。逻辑位序是给用户看的,插入第 1 个位置,进入数组后放在 data[0]。写循环时用 k 从 length 到 i,访问数据时统一做 data[k-1]。也可以在结构体里多分配一个元素,让 data[1] 开始存数据,data[0] 空着,这样逻辑和物理完全对齐,但不推荐,因为会浪费内存且破坏习惯。
5.2 函数运行完没变化:传值传递让修改丢得干干净净
现象是:InitList(L) 调用后,main 里 L.length 仍然是随机值。原因是函数参数是值传递,函数内部拿到的是 L 的拷贝,修改拷贝对原结构体无任何影响。C 语言里结构体还能整个传值,但数组会自动退化成指针,两类参数的行为不一致,容易让初学者搞混。
解决方法是:凡是要修改结构体内容,一律传指针。函数定义写 InitList(SqList *L),调用写 InitList(&L),函数内部用 L->length 访问成员。调试时如果你发现调用后数据没变,不要怀疑逻辑,先检查形参是不是指针,这一步能省掉大量排错时间。
5.3 malloc 之后忘了 free,内存泄漏不一定立刻报错
现象是:链表、二叉树代码跑的时候没问题,但反复创建销毁结构体后,程序内存占用越来越高,最后在某次大批量插入时崩溃。原因是每次 malloc 一个新节点都是在堆上分配内存,不调用 free 就不会归还,系统内存被慢慢耗光。很多学生在实验课上只跑一次,根本察觉不到问题,直到加的测试次数变多才翻车。
解决方法是建立“一个 malloc 配一个 free”的意识。写链表删除函数时,删掉节点不要只改指针,要先把节点从链上摘下来,再 free;写销毁函数时,用遍历的方式一个一个释放,而不是直接 free 头结点,那样会漏掉后面所有节点。更谨慎的做法是在调试阶段用一个全局计数器记录 malloc 和 free 的次数,程序结束前对比两个数是否相等。
5.4 二叉树递归到死:先检查终止条件再检查参数
现象是:递归中序遍历一棵小树一切正常,换一棵深度较大的树后程序在某个节点反复进入同一层,甚至栈溢出崩溃。原因是递归终止条件写错或落后。比如用 if (t->lchild != NULL) 代替 if (t == NULL) return,那么在空树和叶子节点时就会出现对空指针子域的判断;更常见的错误是递归调用传参时把左右孩子写反,导致永远在同一棵子树里打转。
解决方法是把“判空返回”作为递归函数第一行,先建立终止条件再处理业务逻辑。写完后用一两层的二叉树验证:根节点只有左孩子、只有右孩子、左右都为空这三种小数据,能过这三组基本就说明递归结构没写错。
5.5 书上代码用全局变量,搬到自己工程里互相污染
现象是:把严蔚敏书里某个算法段落照抄下来,里面的表或栈被定义成全局变量。同一个程序里跑两组测试时,前一组的残值影响了后一组的运行结果。原因是教材为了描述简洁,把一些工作变量放到了函数外,环境变量一多,函数之间的状态就纠缠在一起了。
解决方法是把全局状态收进结构体,例如把“当前栈顶 top”封装到栈结构体里,将节点指针作为参数传入函数;或者在每个测试函数里先调用 Init 函数重置状态。习惯上我写实验代码时会保证每个 main 只测一个场景,测第二个场景前重新初始化所有数据结构,而不是依赖代码里某个“反正上次已经清零了”的假设。
6. 调试与验证:给数据结构代码补一个可复测的测试骨架
6.1 malloc 之后立刻初始化指针
这是一个性价比极高的习惯。写链表或二叉树节点时,malloc 成功后的第一件事不是赋值 data,而是把 next 或 lchild 置为 NULL,再填数据。这个顺序看着不起眼,却能挡住一大类野指针崩溃。
#include <assert.h> BiTNode *make_tree_node(ElemType v) { BiTNode *p = (BiTNode *)malloc(sizeof(BiTNode)); assert(p != NULL); /* 内存分配失败立刻暴露 */ p->data = v; p->lchild = NULL; /* 先置空,再让调用方挂接 */ p->rchild = NULL; return p; }6.2 跑通三组边界数据再提交
我给自己的测试规则是:任何数据结构代码都要先喂三组数据。空表或空树;只有一个节点;普通规模加退化形状,比如链表退化成单节点、二叉树退化成一串右孩子。空数据能验证判空逻辑,单节点能验证首尾操作,退化形状能验证算法对极端深度的承受力。这三组跑完,代码的架子基本就是稳的。
6.3 用打印定位问题:指针别瞎猜
调试链表时,与其盯着代码发呆,不如遍历打印每个节点的地址和 next 地址,看看哪里断链了。我以前写链表节点时总忘记初始化 next,程序跑着跑着就飞到不知道哪里;后来养成每个 malloc 后先置空指针的习惯,这类问题基本绝迹了。希望帮到你。
本文还有配套的精品资源,点击获取