简介:严蔚敏版《数据结构》配套C++代码实现,面向正在啃教材或备战考研、期末考的数据结构学习者。这份材料把书中大量伪代码落成可运行程序,覆盖数组线性表、链表线性表、双向链表、顺序/链栈、各类队列、KMP匹配、二叉树前中后层次遍历及线索化、图的邻接表与十字链表遍历、无向图生成森林、普里姆最小生成树、拓扑排序、快速/希尔/堆排序,代码附带注释,便于对照教材逐段理解。资源包内仅1个doc文档,约521KB,阅读、检索与打印都很方便,适合在算法实现卡壳时查阅具体写法。目前已有1549人学习下载,对理清抽象数据结构的实际编码很有帮助。若能结合编译环境亲手调试验证,可进一步提升对树、图、排序算法的掌握程度。
1. 严蔚敏版《数据结构》的代码实现:一本教材和一摞段错误的距离
很多人是从“翻开严蔚敏版《数据结构》C语言版教材、把第一段算法抄进编译器”开始崩溃的。严蔚敏版《数据结构》的代码实现,就是解决这个问题的过程:把教材上用类C语言写的抽象算法,改写成能真正编译运行的C程序,包括结构体定义、内存管理、状态返回和入口函数。它能帮你对付考研408代码必背、复试手撕代码、期末复习和数据结构实验报告,也能让“算法看得懂、代码写不出”的人找到一条可重复的操作路径。这里先说明:这一篇只讲代码实现这条线——怎么把书上的黑匣子变成自己手里能跑、能改、能查错的程序。
2. 跑通第一个程序:编译环境、最小骨架与文件怎么拆
2.1 编译环境怎么选:.c后缀、C编译器,别让C++规则捣乱
严蔚敏教材的算法描述基于C语言,但带着浓重的“类C”风格。第一步不是急着敲代码,而是把编译环境选对。常见做法是装Dev-C++或Code::Blocks,它们自带MinGW的gcc编译器,对新手上手最友好;用Visual Studio也可以,但新建项目时必须选“空项目”,并把源文件后缀存成.c,否则编辑器会用C++规则编译你的代码。
这里有个很现实的坑:同样的代码,按.c编译可能只报一两个错,按.cpp编译会报出一堆类型转换失败。C++对malloc返回值的类型检查比C严格,教材里那种L = (LinkList)malloc(sizeof(LNode))的写法,在C++里必须强转,在C里不转也能过。为了避免这种和数据结构无关的干扰,我一般建议新人在初学阶段就统一用C语言编译,把注意力放在逻辑上。
一个容易忽略的设定是代码页编码。教材配套代码常用中文注释,Windows下Dev-C++默认ANSI编码能正常显示,但你自己新建文件时如果用了UTF-8,控制台输出中文就可能乱码。这不是算法问题,却会浪费你半小时。我的习惯是:代码文件统一用UTF-8,printf里尽量少输出中文,用英文提示符代替,省去一套麻烦。
2.2 最小骨架:三元组的可运行代码与Status约定
严蔚敏教材第一章讲抽象数据类型时用“三元组”做例子,这是全书第一个“讲讲很简单、写写不会”的内容。它没有复杂的指针结构,却能一次性讲清楚教材代码和真实代码之间的三层差别:自定义类型、返回值约定、参数传递方式。先看这段能直接编译运行的骨架:
#include <stdio.h> #include <stdlib.h> #define OK 1 #define ERROR 0 typedef int Status; // 教材里的 Status 是自定义返回类型 typedef int ElemType; // 元素类型先定义为 int,后续可替换 typedef struct { ElemType *elem; // 动态数组,存放三个元素 int length; // 当前元素个数,三元组恒为 3 } Triplet; // 初始化三元组,T 是结构体指针 Status InitTriplet(Triplet *T, ElemType v0, ElemType v1, ElemType v2) { T->elem = (ElemType *)malloc(3 * sizeof(ElemType)); if (!T->elem) { return ERROR; // 分配失败时返回 0 } T->elem[0] = v0; T->elem[1] = v1; T->elem[2] = v2; T->length = 3; return OK; } // 取第 i 个元素,i 从 1 开始,结果通过指针带出 Status GetElem(Triplet T, int i, ElemType *e) { if (i < 1 || i > T.length) { return ERROR; } *e = T.elem[i - 1]; return OK; } int main() { Triplet t; ElemType e; if (InitTriplet(&t, 10, 20, 30) == OK) { GetElem(t, 2, &e); printf("第2个元素: %d\n", e); } free(t.elem); // 与 malloc 配对,释放内存 return 0; }这段代码有三个必须理解的约定。第一,Status不是C语言自带的类型,是教材为了描述算法统一性自创的,真实代码里要么typedef int Status,要么干脆不用、直接用int作为返回类型。第二,GetElem里为什么传Triplet T而不是Triplet *T?因为读取操作不需要修改结构体本身,传值拷贝更安全,但取出的元素要通过第三个参数*e带回来,这种“返回值带状态、结果走参数”的模式贯穿全书。
第三,注意free(t.elem)在main函数末尾。很多初学者照着教材抄完Init就忘掉销毁,程序跑一次看不出来问题,放到循环里跑几百次内存就涨上去了。从最小骨架开始养成malloc和free配对出现的习惯,后面学链表、二叉树时会少踩一大半坑。
2.3 从单文件到分文件:实验报告要求的头文件划分
教材配套的代码实现,在课程设计或实验报告里通常要求分文件组织。常见做法是拆成三个文件:头文件存放结构体定义和函数声明,源文件存放函数实现,测试文件放main函数。拿上面的三元组举例,头文件Triplet.h里写:
#ifndef TRIPLET_H #define TRIPLET_H #define OK 1 #define ERROR 0 typedef int Status; typedef int ElemType; typedef struct { ElemType *elem; int length; } Triplet; Status InitTriplet(Triplet *T, ElemType v0, ElemType v1, ElemType v2); Status GetElem(Triplet T, int i, ElemType *e); #endif头文件里用#ifndef包裹,是为了防止重复包含。很多工程里莫名其妙的“变量重定义”报错,就是头文件没写包含保护。源文件Triplet.c里#include "Triplet.h"再写函数体,测试文件main.c里也#include "Triplet.h"并调用。初次做数据结构实验时,不用急着分文件,单文件跑通后再拆,拆的目的是让你理解“声明和定义分离”,这对后续看大工程代码很重要。
3. 线性表落地:顺序表与单链表的可运行代码和三个边界
3.1 顺序表:动态扩容插入的完整实现
顺序表是线性表最直观的存储方式,逻辑上就是教材说的“一组地址连续的存储单元”。严蔚敏教材给的是SqList结构体,里面三个字段:elem动态数组指针、length当前长度、listsize当前容量。这里直接给出一段带动态扩容的插入实现,这段代码是数据结构实验报告和考研408代码题里出现频率最高的模板之一:
#include <stdio.h> #include <stdlib.h> #define OK 1 #define ERROR 0 #define LIST_INIT_SIZE 100 #define LISTINCREMENT 10 typedef int Status; typedef int ElemType; typedef struct { ElemType *elem; // 存储空间基址 int length; // 当前长度 int listsize; // 当前容量 } SqList; Status InitList_Sq(SqList *L) { L->elem = (ElemType *)malloc(LIST_INIT_SIZE * sizeof(ElemType)); if (!L->elem) { return ERROR; } L->length = 0; L->listsize = LIST_INIT_SIZE; return OK; } // 在第 i 个位置插入元素 e,i 从 1 开始 Status ListInsert_Sq(SqList *L, int i, ElemType e) { if (i < 1 || i > L->length + 1) { return ERROR; // 边界一:i 越界 } if (L->length >= L->listsize) { // 容量不足,用 realloc 扩容 ElemType *newbase = (ElemType *)realloc(L->elem, (L->listsize + LISTINCREMENT) * sizeof(ElemType)); if (!newbase) { return ERROR; // 扩容失败 } L->elem = newbase; L->listsize += LISTINCREMENT; } for (int j = L->length; j >= i; j--) { L->elem[j] = L->elem[j - 1]; // 从后往前移动 } L->elem[i - 1] = e; L->length++; return OK; }这段代码有三个边界条件要讲清楚。第一是插入位置的范围:i最小是1,最大是length + 1,因为允许在表尾追加。很多新手只写了i < 1判断,漏掉i > L->length + 1,结果插入位置过大时数组越界,程序不报错但数据已经被写坏了。第二是扩容用realloc而不是malloc,realloc会保留原数据并返回新地址,但注意扩容失败时返回NULL,所以要先赋给临时变量newbase,确认成功后再赋给L->elem——直接L->elem = realloc(...)的话,失败时原指针也丢了。
第三是元素移动方向:从后往前挪,也就是j从L->length递减到i,这样才能避免前面的元素覆盖掉后面的数据。如果你写成从前往后挪,第一次赋值就会把elem[i]覆盖成elem[i-1],整个表错乱。这三条边界动作,是顺序表代码实现里最值得背的部分,很多408代码题就是在这些细节上挖坑。
3.2 单链表:头结点和二级指针是第一个分水岭
单链表是严蔚敏教材里代码实现跳跃感最强的一节,因为教材用LinkList &L这种C++引用来写初始化函数,到了标准C语言里,引用不存在,必须改成二级指针。理解不了这一层,链表代码就永远在“编译报错”和“运行崩溃”之间反复横跳。下面是一段能直接跑通的最小单链表实现,包含头结点初始化、头插法和打印:
#include <stdio.h> #include <stdlib.h> typedef int Status; typedef int ElemType; typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; // 初始化带头结点的空链表 // 参数是 LinkList *,即二级指针 Status InitList_L(LinkList *L) { *L = (LNode *)malloc(sizeof(LNode)); if (!*L) { return ERROR; } (*L)->next = NULL; // 头结点指针域置空,这一步不能省 return OK; } // 头插法:每次把新结点插到头结点之后 Status ListInsert_L(LinkList L, ElemType e) { LNode *s = (LNode *)malloc(sizeof(LNode)); if (!s) { return ERROR; } s->data = e; s->next = L->next; // 新结点指向原来的首元结点 L->next = s; // 头结点指向新结点 return OK; } void PrintList(LinkList L) { LNode *p = L->next; // 跳过头结点 while (p) { printf("%d ", p->data); p = p->next; } printf("\n"); } int main() { LinkList L; InitList_L(&L); // 传的是 LinkList 变量的地址 ListInsert_L(L, 10); ListInsert_L(L, 20); ListInsert_L(L, 30); PrintList(L); // 输出: 30 20 10 return 0; }为什么初始化要用InitList_L(&L),而插入用ListInsert_L(L, e)?这是初学者最容易绕晕的地方。初始化函数要修改L本身——给它分配内存,而L本身已经是一个指针,想在一个函数里改变调用方的指针值,就必须传指针的地址,也就是二级指针。插入函数不修改L,只修改L->next,所以传一级指针就够了。
这个设计不是教材故意刁难,而是C语言参数传递的本质决定的:函数参数是值传递,形参的修改影响不了实参。记住一个口诀:只要函数里要malloc一个新的头结点赋值给外部的链表头变量,就必须用二级指针;如果只修改结点内部字段,一级指针足够。这也是复试面试官最喜欢追问的点,你能说清楚“为什么Init用二级指针、Insert用一级指针”,手撕链表代码这关基本就过了。
3.3 两张表的取舍:时间复杂度与调试成本
顺序表和链表的选型,考研和面试常考,代码实现层面也有实际意义。顺序表随机访问是O(1),插入删除要移动元素,平均O(n);链表插入删除只需改指针,但查找第i个元素要遍历,也是O(n)。实际面试手撕代码时,我一般会先问清“是否频繁插入删除、是否随机访问”,再决定写哪种。
从调试成本看,顺序表比链表友好得多。顺序表的内存是连续的,数组越界时用调试器看elem[j]还能定位;链表的指针一旦指错,轻则打印出乱码地址,重则段错误直接崩溃,而且崩溃位置往往和错误位置隔了好几个函数调用。这也是为什么我建议初学者先写带头结点的单链表版本——头结点让“空表”和“非空表”的插入删除代码统一了,少写一半if分支。
链表调试有个土办法非常管用:写一个PrintList,在每次插入、删除后都打印一遍链表。你能肉眼看到“30 20 10”这样的顺序,说明指针链接没断;如果只输出了一个数或者死循环打印,多半是next指向了自己,或者头结点直接被覆盖。打印法是链表调试的后悔药,比看半天代码猜快得多。
4. 树和图怎么“写出来”:递归遍历、邻接矩阵建图与输出验证
4.1 二叉树的递归创建与三种遍历:把递归出口写对
二叉树是严蔚敏教材从线性结构跨越到非线性结构的第一站。树这部分的代码实现,核心是吃透递归:递归创建、递归遍历、递归求深度。教材里用先序序列加空标记#来创建二叉树,这个逻辑在真实C代码里落地时,最常见的坑是scanf读取字符时把换行符吞进来。代码实现如下:
#include <stdio.h> #include <stdlib.h> typedef int Status; typedef char ElemType; typedef struct BiTNode { ElemType data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; // 先序创建二叉树,输入序列用 # 表示空结点 // 注意 scanf 的 " %c" 前面有空格,用于跳过空白字符 Status CreateBiTree(BiTree *T) { char ch; scanf(" %c", &ch); if (ch == '#') { *T = NULL; // 空子树 } else { *T = (BiTNode *)malloc(sizeof(BiTNode)); if (!*T) { return ERROR; } (*T)->data = ch; CreateBiTree(&(*T)->lchild); // 递归创建左子树 CreateBiTree(&(*T)->rchild); // 递归创建右子树 } return OK; } void PreOrder(BiTree T) { if (T) { printf("%c ", T->data); // 先访问根 PreOrder(T->lchild); PreOrder(T->rchild); } } void InOrder(BiTree T) { if (T) { InOrder(T->lchild); printf("%c ", T->data); // 根在中间就是中序 InOrder(T->rchild); } }创建二叉树也用二级指针,原因和链表Init一样:函数内要修改指针变量T的值。递归创建时,&(*T)->lchild传进去的是左孩子指针的地址,这样子函数分配的结点才能挂到父亲的左指针上。这是二叉树代码实现里最“绕”也最值得想通的地方,一旦理解,后面线索二叉树、哈夫曼树的代码都顺了。
scanf(" %c", &ch)里%c前面的那个空格是很多新手忽略的细节。%c不会像%d那样自动跳过空白符,如果你上一行刚用scanf("%d", &n)读入过数字,缓冲区里还留着一个换行符,下一次scanf("%c", &ch)读到的就是换行而不是你的字符。加一个空格就能让scanf跳过所有空白字符,读取到真正的输入。
三种遍历的区别只有一个printf的位置:根左右是先序,左根右是中序,左右根是后序。记忆方法很朴素:看你把printf放在两个递归调用的前面、中间还是后面。递归遍历的代码只需要背一种,另外两种挪一行printf就到手了。真正要关注的是递归出口——if (T)这个判断,空结点直接返回,这是防止无限递归的唯一防线。
4.2 图的邻接矩阵建图与深度优先搜索:两个数组搞定
图在严蔚敏教材里占据了大量篇幅,但落实到考试和实验代码,最高频的是邻接矩阵存无向图、深度优先搜索遍历。邻接矩阵的实现思路很直白:一维数组vexs存顶点,二维数组arcs存边,arcs[i][j]为1表示顶点i和j之间有边。下面是一个最小可运行的建图加DFS实现:
#include <stdio.h> #include <stdlib.h> #define MAX_VEX 100 typedef int Status; typedef struct { char vexs[MAX_VEX]; // 顶点表 int arcs[MAX_VEX][MAX_VEX]; // 邻接矩阵 int vexnum, arcnum; // 顶点数和边数 } MGraph; int visited[MAX_VEX]; Status CreateGraph(MGraph *G) { printf("输入顶点数和边数: "); scanf("%d %d", &G->vexnum, &G->arcnum); printf("输入顶点字符: "); for (int i = 0; i < G->vexnum; i++) { scanf(" %c", &G->vexs[i]); } // 邻接矩阵初始化 for (int i = 0; i < G->vexnum; i++) { for (int j = 0; j < G->vexnum; j++) { G->arcs[i][j] = 0; } } printf("输入每条边的两个顶点下标(从0开始):\n"); for (int k = 0; k < G->arcnum; k++) { int i, j; scanf("%d %d", &i, &j); G->arcs[i][j] = 1; G->arcs[j][i] = 1; // 无向图矩阵对称 } return OK; } void DFS(MGraph G, int v) { printf("%c ", G.vexs[v]); visited[v] = 1; for (int j = 0; j < G.vexnum; j++) { if (G.arcs[v][j] && !visited[j]) { DFS(G, j); // 递归访问邻接点 } } }这个实现里有几个经常让新手翻车的细节。第一,顶点下标从0开始,而教材很多伪代码习惯从1开始。如果你按教材抄,把顶点1到n直接存成vexs[1]到vexs[n],那arcs的边界判断会变得很别扭。我建议在代码实现阶段统一0-based下标,只在注释里标注“教材里的v1对应这里的0”。第二,无向图的邻接矩阵是对称的,arcs[i][j]置1时必须同步置arcs[j][i],漏掉这一步,DFS遍历出来的结果就会少走很多边。
第三,visited数组是全局变量,每次调用DFS前必须清零。常见的错误是第二次调用DFS时visited还残留着第一次遍历的标记,导致第二个连通分量一个顶点都访问不到。如果不想用全局变量,就把visited作为参数传进去,但那样每个递归调用都要带着它,代码会啰嗦不少。实际考试和实验中,全局visited是公认的简洁写法。
BFS(广度优先)是DFS的姊妹篇,思路是用队列保存待访问顶点。教材里用顺序队列那套代码,到了自己写的时候,用最简单数组模拟队列就够用:一个queue数组,两个头尾下标。DFS练熟之后,把递归调用换成入队出队循环,BFS自然就通了。图的这部分实现,没必要追求代码行数少,重点是建图的输入输出闭环——能建出图、能遍历出序列,就说明你的邻接矩阵真建对了。
4.3 树和图之外的二选一:排序和查找在实验里的占比
严蔚敏教材后半部分的排序和查找,在“代码实现”这个需求里其实分成两类待遇。排序代码(插入、快排、归并)几乎是408代码必背的常客,但它们是独立于树和图的逻辑,不需要复杂结构体,数组加循环就能写。而查找里的哈希表、二叉排序树,本质是复用前面学过的链表、二叉树代码,没有新的结构难题。
如果你是冲着期末复习或实验报告来的,我给一个优先级建议:先保证顺序表、单链表、二叉树递归遍历、邻接矩阵DFS这四段代码能独立写出来,再去背排序。因为前四个是后面所有内容的结构基础,且代码量小、容易验证;排序算法虽然看起来短,但边界条件和递归版本的分治逻辑,需要单独花时间调试,性价比不如先把结构类代码吃透。有些同学排序背得滚瓜烂熟,结果实验报告里要求二叉树遍历时卡住,这就是把精力放错了地方。
5. 严蔚敏版代码常见的“照抄翻车”:五个高频坑与排查顺序
5.1 编译期报错:Status和引用参数是教材代码和C语言的裂缝
现象:把教材里的InitList_L(LinkList &L)原样敲进.c文件,编译器报expected ';' before '&' token,或者报Status未定义。
原因:教材用的是类C描述语言,&是C++的引用语法,标准C里没有;Status、OK、ERROR这些类型和宏也是教材自建的,编译器不知道。
解决:统一在文件头部补上typedef int Status; #define OK 1 #define ERROR 0,把函数签名里的&改成*,调用处给实参加&。比如教材写InitList_L(&L)你已经是对的,教材写InitList_L(L)而形参是LinkList &L,你的C代码就要改成InitList_L(&L)配合LinkList *L形参。
5.2 运行期崩溃:野指针和没初始化的头结点
现象:编译一次通过,运行到链表插入或打印时直接“无响应”,调试器定位到p = p->next这行。
原因:malloc分配的内存内容是随机的,不会自动清零。InitList_L里如果只分配了头结点就结束,没有写(*L)->next = NULL,那么头结点的next就是一个野指针。插入时L->next拿到的不是NULL而是垃圾地址,循环打印就会顺着垃圾地址越走越远。
解决:所有结点分配后立刻初始化指针字段。单链表头结点必须(*L)->next = NULL;二叉树新结点必须(*T)->lchild = (*T)->rchild = NULL。写代码时把“分配后马上初始化”当肌肉记忆,能规避掉一大半段错误。
5.3 数据一多就栈溢出:递归没有出口或深度过大
现象:二叉树只有十几个结点时遍历正常,换成几百个结点后程序崩溃;或者链表递归删除时结点一多就中断。
原因:递归每层调用都在系统栈上占空间。二叉树的递归深度等于树的层数,如果树退化成单支链(比如按顺序插入生成的二叉排序树),深度就等于结点数,几百层就可能把栈撑爆。另一个可能是递归出口写错:if (T)写成了if (T->lchild),遇到NULL结点继续访问成员,直接段错误。
解决:小数据下先用打印确认递归逻辑正确,再考虑数据规模。考研和期末的代码题数据量通常很小,先保证递归写法正确。如果明确知道数据可能很大,把递归改成循环加显式栈,遍历顺序不变但不会再栈溢出。
5.4 scanf吞字符:%c读进来的不是你想的字符
现象:创建二叉树时输入AB##C##,程序却只读到一个字符就停住,或者树里存进去的data是换行符、空格。
原因:%c会读取缓冲区里的任意字符,包括回车、空格。前面用过scanf("%d", &n)读数字后,缓冲区留了一个换行符,紧接着的scanf("%c", &ch)就把它消费了,真正的字符反而留在缓冲区里。
解决:所有读字符的scanf都用" %c",也就是格式串里加一个前导空格,让scanf先跳过所有空白字符。另一个更稳的办法是不用交互输入,在main里准备一个字符串数组,用str模拟输入流,比如sscanf逐个解析,这样逻辑可重复验证,不用每次手敲。
5.5 内存泄漏和重复释放:malloc/free不配对
现象:程序跑起来内存占用持续上涨;或者链表删除结点后继续访问被删结点的next,程序偶发崩溃。
原因:C语言没有垃圾回收,每个malloc都必须有对应的free。删除链表结点时,如果你先把pfree了,再去访问p->next,就是读一块已释放的内存,行为未定义,时好时坏,这属于典型的玄学崩溃。
解决:删除结点的顺序必须是“先保存后继,再free”。标准写法:q = p->next; p->next = q->next; free(q);。同时给每个Init配一个Destroy,把整表释放,释放时先用next保存后继再free当前结点,循环直到NULL。写代码时每出现一次malloc,就顺手写对应的释放逻辑,不要攒到最后统一补。
6. 用断言和打印验证实现:不依赖调试器的自查方法
验证严蔚敏版代码实现写没写对,最直接的办法不是上调试器,而是把“打印”和“断言”铺满代码路径。先说打印:每写完一个数据结构,第一件事是写一个遍历打印函数。链表写PrintList,二叉树写三种递归遍历,图写DFS和BFS。能稳定输出序列,说明结构本身的链建对了;输出和你手算的预期不一致,说明逻辑有偏差,这时候再逐行走读代码。打印法看着笨,但它是所有验证方式里信息量最大的,一行输出能同时暴露指针链接错误、遍历顺序错误和下标越界问题。
再说断言。在关键函数入口加assert做前置校验,比如顺序表插入前assert(i >= 1 && i <= L->length + 1),链表删除前assert(p != NULL)。程序正常时断言不触发,一旦触发就精确告诉你哪个条件破坏了,比运行到一半崩溃再回头猜快得多。我自己写实现时还有一个习惯:每个Init后面立即调用对应的Destroy,先把这条生命周期跑通,再往里填业务逻辑。这个习惯救过我很多次,尤其是早期跳过销毁函数直接写插入删除,结果在链表上把内存释放的顺序搞反,调试到深夜才定位到问题。
验证的最终标尺是输入输出闭环。拿教材上的经典例题当测试集:顺序表用“合并两个有序表”验证,链表用“约瑟夫环”验证,二叉树用“先序序列AB##C##”验证,图用教材里那个固定顶点边数的例子验证。每一步都有打印输出,对照教材习题答案逐项核对,全对才算这段实现真的落地了。希望这些方法和踩过的坑,能帮你把严蔚敏版《数据结构》从“读过”变成“写过”。
本文还有配套的精品资源,点击获取