☰
严蔚敏数据结构C语言代码包全解析:核心算法与避坑指南
2026/10/10 3:54:57 网站建设 项目流程

简介:数据结构与算法是计算机科学的核心基础课程,代码实践是将原理落到实处的关键。严蔚敏《数据结构与算法(C语言版)》教材的配套代码实现包,面向计算机专业学生及需要巩固算法基础的C/C++开发者。资源以教材章节为线索,覆盖线性表、栈与队列、二叉树、二叉搜索树与平衡树、堆、图论与网络流、排序查找、动态规划、贪心算法、回溯与分治策略等经典专题,并提供对应C语言可运行源码;读者可对照教材逐章验证每种数据结构的存储设计与操作实现,也能直接学习函数封装和边界处理。压缩包共416个文件,以154个C、154个C++源文件和89个头文件为主体,辅以数据文件、文本说明和工程配置,文件命名与教材示例编号对应,便于直接编译调试,整体仅494KB。已有1607人学习,代码适合作为课程设计参考与面试算法复习手册,无论是课程实验、期末复习还是准备算法面试,都能提供直观范例与快速调试环境,帮助把抽象概念转化为可上机运行的实现,尤其适合严蔚敏教材自学者边读边练。

1. 严蔚敏数据结构C语言代码包:为什么我拿到手先翻了三遍

某同学从网盘下了一份号称“严蔚敏《数据结构(C语言版)》代码实现”的压缩包,解压之后对着十来个文件夹发呆——里面既有顺序表又有图算法,却不知道从哪个文件开始看,更别说能不能编译通过。这套资源对应的正是教材里从线性表到查找排序的整套可运行C代码,直接服务于数据结构实验报告、数据结构期末复习和考研数据结构刷题场景。多数打包者会把头文件、源文件按章节分开,拿到手正确的打开方式不是挨个点开,而是先看目录结构、确认编译环境,再挑一个最简单的顺序表程序验证工具链。适合三类人:正在抄实验报告但不想全抄的学生、准备考研笔试想动手跑算法的考生、以及从C++转过来想补C指针功底的开发者。

2. 线性表与串的实现:从顺序表初始化到KMP匹配的几个关键动作

2.1 顺序表:结构体定义、初始化与插入的代码拆解

线性表是所有后续算法的基础,而顺序表又是线性表里最容易在传参上踩坑的部分。严蔚敏版教材里顺序表用结构体封装数组和长度,定义一般长这样:

#define MAXSIZE 100 typedef struct { int data[MAXSIZE]; int length; } SeqList;

配套的初始化函数并不复杂,但经常有人写成传值导致 length 永远改不回去:

// 注意形参是指针,不是 SeqList L void InitList(SeqList *L) { L->length = 0; // 写成 L.length = 0 的话,main里不会有任何变化 }

初始化本身的逻辑很简单,真正的分水岭在插入操作。插入的核心是“从最后一个元素开始依次后移”,代码长这样:

int ListInsert(SeqList *L, int i, int e) { int k; if (i < 1 || i > L->length + 1) return 0; // 插入位置越界 if (L->length >= MAXSIZE) return 0; // 表已满 for (k = L->length; k >= i; k--) { L->data[k] = L->data[k - 1]; // 从后往前挪 } L->data[i - 1] = e; L->length++; return 1; }

这段代码有两个容易忽略的设计。一是越界判断必须同时看下限和上限:i 小于 1 不行,i 大于 length+1 也不行,等于 length+1 其实是允许的,因为那等于在末尾追加。二是后移必须从后往前循环,如果写成for (k = i - 1; k <= L->length; k++) data[k+1] = data[k],前面元素会把后面还没挪的位置覆盖掉,结果全表变成同一个值。参数 i 是逻辑序号,和数组下标差 1,这也是初学阶段最容易绕晕的地方。

2.2 链表:带头结点创建和删除时的指针误用

链表比顺序表多一层指针间接,资源包里最常见的翻车点是“修改节点内容后 main 函数没反应”。链表节点定义一般直接沿用教材的结构体:

typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList;

头结点的价值在于让“删除第一个元素”和“删除其他元素”的代码逻辑统一,不需要单独写 if 分支。尾插法创建单链表的常用实现是:

LinkList CreateList(int n) { LinkList head = (LinkList)malloc(sizeof(LNode)); LNode *tail = head; int i; head->next = NULL; for (i = 0; i < n; i++) { LNode *p = (LNode*)malloc(sizeof(LNode)); scanf("%d", &p->data); p->next = NULL; tail->next = p; // 新节点接在尾部 tail = p; // tail 后移 } return head; }

理解这段代码的关键在 tail 指针:它始终指向当前链表的最后一个节点,每插入一个节点就移动一次。head 从头到尾没有变过,始终指向头结点。如果你在循环里让 head 也往后移动,函数返回时链表头就丢了,遍历会从错误位置开始。

删除第 i 个节点时,要先找到第 i-1 个节点再动指针,直接删第 i 个节点会因为拿不到前驱而无法维护链表关系:

int ListDelete(LinkList L, int i, int *e) { LNode *p = L; int j = 0; while (p->next && j < i - 1) { // 找到第 i-1 个节点 p = p->next; j++; } if (!p->next) return 0; // 第 i 个节点不存在 LNode *q = p->next; p->next = q->next; // 绕过 q *e = q->data; free(q); // 释放后务必不再使用 q return 1; }

有人删完节点之后继续打印 q->data 去验证,这是未定义行为,释放过的指针应该立刻置 NULL。还有一个细节:删除接口用int *e把被删元素带出来,这是 C 语言里“函数需要多个返回值”的标准解法。如果你直接返回节点数据,碰到删除失败(比如位置越界)就不知道该返回什么了。

2.3 KMP 匹配:next 数组构造与主串 i 不回退

串这一章,预处理数组和 KMP 匹配常常让新手怀疑人生。严蔚敏教材里字符串是用S[0]存长度、字符从S[1]开始存的设计,数组长度比字符个数多一个。KMP 的 next 数组构造函数,用 C 写出来是:

void GetNext(char *T, int *next) { int i = 1, j = 0; next[1] = 0; while (i < T[0]) { if (j == 0 || T[i] == T[j]) { i++; j++; next[i] = j; // 当前匹配长度 + 1 } else { j = next[j]; // 失配则回退到上一个可用位置 } } }

匹配主函数则利用 next 让 i 一直往前走,不回退:

int Index_KMP(char *S, char *T, int pos) { int i = pos, j = 1; int next[255]; GetNext(T, next); while (i <= S[0] && j <= T[0]) { if (j == 0 || S[i] == T[j]) { i++; j++; } else { j = next[j]; // 只有 j 回退,i 不动 } } if (j > T[0]) { return i - T[0]; // 返回模式串在主串中的起始位置 } return 0; }

KMP 的核心价值在于:主串下标 i 绝不回溯,失配时只移动模式串指针 j。普通 BF 算法每次失配都要i = i - j + 2; j = 1;,一旦主串很长,重复比较的开销非常大。next[j]的含义很难用一句话说全,我自己的理解是“模式串第 j 个位置失配后,下一步用第几个位置的字符继续比较”。配套代码包里如果把字符串逆序或 c语言字符串数组 相关的练习一起看,更容易理解下标设计:字符数组长度必须开成 N+1,T[0]才能安全保存长度。pos参数表示从主串的哪个位置开始匹配,默认传 1,如果传 0 会在循环条件里直接退出,这个细节很多人第一次跑通代码才发现。

3. 树与图:把递归遍历和最短路径的代码跑起来

3.1 二叉树先序、中序、后序:递归栈里的访问时机

树这章最值得花时间的是三种遍历的递归关系。先序、中序、后序的区别只有一行:访问根节点的那句话放在递归调用的前面、中间还是后面。以先序为例:

typedef struct BiTNode { char data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; void CreateBiTree(BiTree *T) { char ch; scanf("%c", &ch); if (ch == '#') { *T = NULL; } else { *T = (BiTree)malloc(sizeof(BiTNode)); (*T)->data = ch; CreateBiTree(&(*T)->lchild); // 先递归建左子树 CreateBiTree(&(*T)->rchild); // 再递归建右子树 } } void PreOrder(BiTree T) { if (T == NULL) return; printf("%c ", T->data); // 访问根节点 PreOrder(T->lchild); // 遍历左子树 PreOrder(T->rchild); // 遍历右子树 }

这段代码里CreateBiTree的参数用了二级指针,原因和顺序表传指针一样:建树过程中要修改*T本身指向的地址,只传一级指针的话malloc分配的内存地址在函数返回后丢失。输入序列用#表示空子树,例如输入AB#D##C##会构建一棵先序序列为 ABDC 的二叉树。PreOrder的递归理解不要纠缠在“栈怎么压”,而是记住每个节点都会经历“访问根-进左子树-出左子树-进右子树-出右子树”这条路径,printf 写在第一步就是先序,写在中间是后序。做数据结构期末复习的时候,把三种遍历的打印序列在纸面上推演三遍,比盲目刷十道题更有用。

3.2 图的邻接矩阵:初始化、DFS 与 visited 数组的作用

图论代码在本资源里通常占两个文件:一个存邻接矩阵结构,一个存遍历和路径算法。邻接矩阵的定义和初始化不长,但容易漏掉对角线:

#define N 100 typedef struct { int edges[N][N]; // 邻接矩阵 int n, e; // 顶点数和边数 } MGraph; void CreateMGraph(MGraph *G) { int i, j, k, w; scanf("%d %d", &G->n, &G->e); for (i = 0; i < G->n; i++) { for (j = 0; j < G->n; j++) { G->edges[i][j] = 0; // 初始化为无边 } } for (k = 0; k < G->e; k++) { scanf("%d %d %d", &i, &j, &w); G->edges[i][j] = w; G->edges[j][i] = w; // 无向图对称赋值 } }

深度优先遍历的骨架也是递归,但多了一个关键辅助数组 visited:

int visited[N]; void DFS(MGraph *G, int v) { int j; visited[v] = 1; printf("%d ", v); for (j = 0; j < G->n; j++) { if (G->edges[v][j] != 0 && !visited[j]) { DFS(G, j); } } }

visited 数组的作用是防止回头路:图不像树那样天然有方向,A 访问邻居 B 之后,B 的邻接表里又会有 A,没有 visited 标记就会在两个节点之间反复横跳直到栈溢出。注意 visited 是全局数组,默认值为 0,但如果你在一次程序里对多个连通分量分别调用 DFS,必须在每次调用前把 visited 清零,否则第二个分量会被跳过。这一点在避坑章节还会细说。至于广度优先遍历,把递归换成队列,出队时访问并把未访问邻居入队,逻辑类似,这两段代码建议对比着看。

3.3 Prim 与 Dijkstra:两个贪心算法的数组更新对比

Prim 最小生成树和 Dijkstra 最短路径在很多教材里挨着讲,是因为它们共享同一套贪心框架。Prim 维护 lowcost 数组,表示“已选集合”到每个未选顶点的最小边权;Dijkstra 维护 dist 数组,表示源点到每个顶点的最短路径长度。核心循环都是“找最小值-固定-更新数组”,但更新条件不同:

void Prim(MGraph *G, int start) { int lowcost[N], mst[N]; int i, j, min, minid; for (i = 0; i < G->n; i++) { lowcost[i] = G->edges[start][i]; mst[i] = start; } for (i = 1; i < G->n; i++) { min = 9999; minid = -1; for (j = 0; j < G->n; j++) { if (lowcost[j] != 0 && lowcost[j] < min) { // 0 表示已在集合内 min = lowcost[j]; minid = j; } } printf("(%d,%d) weight=%d\n", mst[minid], minid, min); lowcost[minid] = 0; // 加入已选集合 for (j = 0; j < G->n; j++) { if (lowcost[j] != 0 && G->edges[minid][j] < lowcost[j]) { lowcost[j] = G->edges[minid][j]; // 只更新边权 mst[j] = minid; } } } }

两者的核心区别在于更新阶段:Prim 只看新选中节点到其他节点的“直接边权”是否比现在的 lowcost 更小;Dijkstra 则要看“从源点到新选中节点的已知最短路径 + 新节点到其他节点的边权”是否小于现有 dist。表格对比更直观:

对比项PrimDijkstra
目标连接所有顶点的最小总边权源点到各顶点的最短距离
数组含义lowcost:集合边权dist:源点路径权
更新条件edges[minid][j] 与 lowcost[j] 比较dist[minid] + edges[minid][j] 与 dist[j] 比较
是否处理负权边不适用不适用(出现负权需换算法)

我自己早年常犯的错是把 Dijkstra 的更新写成if (G->edges[minid][j] < dist[j]),忘记了最短路径的累加属性。如果你同样在做图算法的实验报告,建议在纸上把两个数组各画一张表,跑一轮 5 顶点的小图再对照代码,比直接改 bug 更有效率。这一段看完,图的基本代码就够用了。

4. 查找与排序:折半边界和快排分区的实现细节

4.1 折半查找:left <= right 与取中值方式共同决定会不会死循环

折半查找是查找章节的入门题,也是数据结构折半查找例题里最容易写错的边界题。标准实现:

int BinarySearch(int a[], int n, int key) { int left = 0; int right = n - 1; int mid; while (left <= right) { mid = left + (right - left) / 2; // 防止 left + right 溢出 if (a[mid] == key) { return mid; } else if (a[mid] < key) { left = mid + 1; // 目标在右半区 } else { right = mid - 1; // 目标在左半区 } } return -1; }

循环条件left <= right是必须的,写成left < right会让查找范围收窄到单元素时直接退出,导致最后一个元素永远找不到。mid = left + (right - left) / 2这个写法不只是防溢出,还能保证 mid 始终落在区间中点的左侧,配合left = mid + 1、right = mid - 1就不会出现区间无法缩小而死循环。查找失败时返回 -1 而不是 0,因为下标 0 是合法位置,用 0 表示失败会掩盖“第一个元素命中”的情况。

4.2 快速排序:一次划分里的空位移动与枢轴选择

数据结构排序算法这一章,快排的代码最具戏剧性。一次划分写成独立函数,以数组的第一个元素为枢轴,双指针交替填坑:

int Partition(int a[], int low, int high) { int pivot = a[low]; // 选第一个元素为枢轴 while (low < high) { while (low < high && a[high] >= pivot) high--; // 从右往左扫 a[low] = a[high]; // 右边比枢轴小的移到左边空位 while (low < high && a[low] <= pivot) low++; // 从左往右扫 a[high] = a[low]; // 左边比枢轴大的移到右边空位 } a[low] = pivot; // 枢轴归位 return low; } void QuickSort(int a[], int low, int high) { int pivotPos; if (low < high) { pivotPos = Partition(a, low, high); QuickSort(a, low, pivotPos - 1); QuickSort(a, pivotPos + 1, high); } }

两个内层 while 的等号处理是很多翻车现场的来源:如果写成a[high] > pivot而不是>=,遇到与枢轴相等的重复元素时两个指针可能互相卡住,因为外层 while(low < high) 永远不退出。枢轴选第一个元素有个缺点——如果数组本身有序,每次划分只能消掉一个元素,递归深度变成 n,栈压力很大。我一般会把第一个和中间元素比较后换个位置再选枢轴,这是改动最小且能大幅度降低退化概率的做法。外层递归的终止条件也要写对:if (low < high)而不是if (low != high),否则分区返回的位置和 high 相等时会多递归一层,在小数组上就爆栈。

4.3 堆排序:从 n/2-1 开始的向下调整

堆排序算法是所有 O(n log n) 排序里最难凭直觉写对的一个,因为它的下标映射关系藏在完全二叉树的性质里。用数组存堆时,第 i 个节点的左孩子是 2i+1,右孩子是 2i+2,父亲是 (i-1)/2。建堆要从最后一个非叶子节点开始往前调整,最后一个非叶子节点的下标是 n/2-1:

void HeapAdjust(int a[], int root, int n) { int temp = a[root]; int child; while (2 * root + 1 < n) { child = 2 * root + 1; if (child + 1 < n && a[child] < a[child + 1]) { child++; // child 指向较大的孩子 } if (temp < a[child]) { a[root] = a[child]; // 孩子上移 root = child; // 继续向下检查 } else { break; // 找到合适位置 } } a[root] = temp; } void HeapSort(int a[], int n) { int i, temp; for (i = n / 2 - 1; i >= 0; i--) { HeapAdjust(a, i, n); // 自底向上建大顶堆 } for (i = n - 1; i > 0; i--) { temp = a[0]; a[0] = a[i]; a[i] = temp; HeapAdjust(a, 0, i); // 堆顶换到最后,缩小堆范围 } }

这段代码里最容易犯的错误是把 HeapAdjust 里的循环写成只调整一层就返回。堆调整要求节点一直下沉到子树中合适的位置,所以root = child这一步不能少。第二个容易错的地方是第二次 HeapAdjust 传入的堆长度 i,每轮排序后堆的有效范围减一,之前换到数组末尾的元素已经有序,不能再参与调整。建堆从 n/2-1 开始而不是从 n-1 开始,是因为叶子节点没有孩子,不需要调整,从最后一个有孩子的节点开始可以保证每一层都已经满足堆性质。想验证理解程度,可以把比较符号从<改成>试试建小顶堆,再想想排序结果是升序还是降序。

5. 避坑排查:这套代码里常见的五个翻车现场

5.1 修改函数不生效:结构体传参只传了副本

现象:在 main 里调用InitList(L)后,打印L.length仍然是随机值,链表创建函数拿到 head 之后 main 里 head 还是 NULL。

原因:C 语言函数参数是值传递,直接把结构体变量传进去,函数内部修改的是栈上的一个临时拷贝。结构体数组退化成指针所以看起来正常,但SeqList这种整体变量不会自动传址。

解决:所有需要回写数据的函数,形参改成指针。InitList(SeqList *L)、CreateMGraph(MGraph *G),调用时传&L。检查函数签名,凡是函数内部出现L->xxx和(*G).xxx的都是指针版本,不要混用。

5.2 scanf 吃换行导致二叉树建树错乱

现象:运行CreateBiTree(&T)时输入AB#D##C##,结果第一次 scanf 读到的是换行符,根节点内容变成空白,整棵树结构全乱。

原因:scanf 的%c会读取任意字符包括换行,前一次输入结束时按下的回车停留在输入缓冲区,被下一个%c消费。

解决:建树前加一句getchar();吃掉残留换行,或者用scanf(" %c", &ch)在格式串前面留一个空格跳过空白字符。这属于玄学问题里最经典的一个,很多人排查半小时最后发现是缓冲区问题,从此学会在调试树形代码之前先清理输入状态。

5.3 visited 数组没重置:第二次遍历结果不对

现象:图里有两个连通分量,第一次 DFS 输出正常,第二次调用 DFS 只输出一个节点就停了。

原因:visited 是全局数组,第一次 DFS 已经把第一个分量的节点标记为 1,第二次遍历时这些节点不再满足!visited[j]条件,整个程序直接跳过。

解决:每次调用 DFS 之前写一个循环清零 visited,或者把 visited 放进一个 InitVisited 函数统一管理。这个方法同样适用于 BFS,我在实际写实验代码时习惯把 visited 的定义和清理写在同一对函数里,避免漏调。

5.4 KMP 下标从 1 开始导致数组越界

现象:把模式串正常按 C 习惯存成"abc"后调用Index_KMP,匹配结果不对或者运行时报段错误。

原因:严蔚敏版字符串约定是下标 0 存长度,字符从下标 1 开始。T[0]原本存的是串长,如果直接存字符'a',ASCII 值是 97,循环次数完全错乱。

解决:给字符串数组分配长度 +1 的空间,把串长写进T[0],字符从T[1]开始赋值。例如char T[4]; T[0] = 3; T[1]='a'; T[2]='b'; T[3]='c';。这在资源包配套代码里是通用约定,不是某一处函数的特殊写法。

5.5 快排在有序数组上递归过深

现象:对已经升序的数组执行 QuickSort,程序在 n 接近一万时栈溢出崩溃,换成无序随机数组就没事。

原因:枢轴永远选第一个元素,而有序数组的划分结果每次都是枢轴在最边上,递归深度从期望的 log n 退化到 n,运行时栈被压穿。

解决:选枢轴前把a[low]与a[mid]、a[high]三者比较,取中位数放到 low 位置再 Partition。这个改动不会破坏排序正确性,但能显著降低最坏情况出现概率。对于数据量特别大且可能有序的场景,还可以在递归深度超过一定阈值时切换堆排序,这就是优化版 introsort 的思路。

6. 一个进阶技巧:把调试开关和随机测试数据固化到工程里

6.1 用 DEBUG 宏统一控制调试输出

从这套代码包里复现算法时,最烦的是每改一处都要手动找 printf 删掉。我后来的习惯是开头统一加一个调试宏,所有带诊断信息的输出都走它:

#ifdef DEBUG #define LOG(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__) #else #define LOG(fmt, ...) do {} while (0) #endif

编译时加-DDEBUG就开启日志,去掉就静默。代码里用LOG("插入后 length=%d", L->length)代替裸 printf,保留关键中间状态但不用反复注释。##__VA_ARGS__是 GNU 扩展,在 Windows 上用 MSVC 编译需要改成__VA_ARGS__。这个宏在验证排序划分是否正确、检查 KMP 的 next 数组是否按预期更新时作用很大,尤其是数据规模一大,肉眼根本看不清过程值,加两行 LOG 立刻定位问题。

6.2 每次改完算法先跑“小数据、边界、随机”三步验证

学这套代码最容易陷入的误区是跑通一个用例就宣告完成。我的验证顺序固定是:先拿 5 个元素的小数组手动推演一遍,再构造边界数据——空表、单元素、全相同元素、升序有序数组,最后用随机数据生成器跑大量测试与暴力算法对拍。

void GenRandom(int a[], int n, int maxVal) { int i; for (i = 0; i < n; i++) { a[i] = rand() % (maxVal + 1); } }

对拍的意思是同一个输入分别跑自己写的算法和一个确定正确但效率低的版本,比较输出是否一致。排序可以对比冒泡;匹配可以对比 BF 暴力函数;查找可以对比循环遍历。这一步能验证大部分隐藏缺陷,特别是快排的等号问题、堆排序的边界问题,都是随机大量数据才能暴露出来的。我从那以后每次拿到新的算法代码,都强制走一遍这个流程:先确认编译器和代码的字符串下标约定,再用 DEBUG 宏跑小数据,接着用边界值折磨函数,最后随机数据对拍。这个习惯救了我很多次,希望帮到你。

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

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

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

立即咨询