简介:这份资源面向正在学习数据结构课程的高校学生与考研备考者,针对严蔚敏《数据结构 C语言版》第2版课后算法设计题,提供经过系统校对的参考答案与书中算法源码。全部代码基于CLion 2020至2021开发,按CMake文件描述部署后即可直接编译运行,作者还对部分算法做了优化,纠正了参考答案中的错误,并针对可能出现的bug、触发条件、不同实现思路及执行过程给出了详细说明。压缩包为rar格式,整体约3.14MB,内含源码文件与说明文档,另附五本经典算法或数据结构书籍的获取链接,写在ReadMe.txt中。目前已有1220人学习下载,适合希望对照源码理解算法细节、排查实现问题并提升编程能力的读者参考使用。
1. 从一份能直接跑起来的严蔚敏《数据结构》源码包说起
如果你正在啃严蔚敏《数据结构 C语言版》第2版,大概率遇到过这种局面:书上的算法伪代码看懂了,课后算法设计题却不知道从哪下手;网上搜到的“参考答案”要么是扫描版看不清,要么代码跑不起来,要么干脆是错的。这份资源就是冲着这个痛点来的——它把书中的算法源码和课后算法设计题的答案整理成了一套完整的 C 语言工程,用 CLion 2020~2021 配合 CMake 组织,拉下来按 CMake 描述部署就能直接编译运行。作者还做了一件很实在的事:对参考答案里的错误逐条纠正,对可能触发的 bug、不同实现思路、执行过程都写了说明。适合正在做课后题的学生、准备考研复试上机的人,以及想拿一份干净 C 语言数据结构代码当参考的开发者。
2. 工程结构与 CMake 部署:先让代码跑起来
2.1 目录组织与模块划分
拿到一个 C 语言工程,第一件事不是急着看算法,而是搞清楚它怎么组织。这份资源按书本章节划分模块,常见做法是每个数据结构一个独立目录,比如线性表、栈与队列、串、树与二叉树、图、查找、排序各占一块,每个目录下再分头文件、源文件、测试入口。CMakeLists.txt 通常放在根目录,用add_subdirectory把各模块挂进来,或者用file(GLOB ...)收集源文件。
我一般会先看根目录的 CMakeLists.txt,确认三件事:C 标准设的是多少、有没有开编译警告、可执行目标有几个。这份资源用的是 CLion 2020~2021 时代的 CMake 写法,cmake_minimum_required版本不会太高,兼容性反而好。
cmake_minimum_required(VERSION 3.15) project(DataStructure_C) # 统一用 C99,严蔚敏书里的代码基本在这个标准下能编 set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED ON) # 打开常用警告,方便发现书里代码的隐式类型转换问题 add_compile_options(-Wall -Wextra) include_directories(${CMAKE_SOURCE_DIR}/include) add_subdirectory(linear_list) add_subdirectory(stack_queue) add_subdirectory(tree) add_subdirectory(graph) add_subdirectory(sort_search)这段配置的逻辑很直白:CMAKE_C_STANDARD 99是因为书里大量用了 C99 的变量声明位置和//注释;-Wall -Wextra是我强烈建议保留的,严蔚敏书里不少算法在类型转换上有隐患,开了警告能提前暴露;include_directories把公共头文件目录挂上,各模块就能互相引用。
2.2 在 CLion 里部署与首次编译
CLion 对 CMake 工程的支持是开箱即用的,但有几个参数值得手动确认。打开工程后,进入Settings → Build, Execution, Deployment → CMake,检查CMake options里有没有额外的-D定义,Build directory默认是cmake-build-debug,保持默认即可。
# 如果你不想用 IDE,纯命令行也能跑 mkdir build && cd build cmake .. make -j4 # 运行某个模块的测试入口,比如线性表 ./linear_list/linear_list_test命令行这套流程的好处是可复现,换台机器照样能编。-j4是按 CPU 核心数并行编译,模块多的时候能省不少时间。编译完如果某个模块报错,先别急着改代码,看错误信息里有没有implicit declaration或incompatible pointer type,这两类问题在这类老代码里最常见,通常是头文件没包含全或者函数声明和定义对不上。
提示:CLion 2020 和 2021 对 CMake 的最低版本要求略有差异,如果导入时提示 CMake 版本过低,把
cmake_minimum_required那行改成你本地 CMake 支持的版本即可,不影响代码本身。
2.3 编译目标与运行入口的对应关系
一个容易被忽略的点是:这份资源里每个数据结构模块通常有多个可执行目标,一个是算法本身的演示,一个是课后题的测试。CMake 里用add_executable分别定义,名字一般能看出来,比如singly_linked_list_demo和singly_linked_list_exercise。跑之前先确认你要验证的是哪个,别跑错了入口还以为是代码有问题。
# 以单链表为例,演示入口和习题入口分开 add_executable(singly_linked_list_demo demo/singly_linked_list_demo.c src/singly_linked_list.c) add_executable(singly_linked_list_exercise exercise/singly_linked_list_exercise.c src/singly_linked_list.c)这种拆法的好处是,演示代码保持和书本一致,方便对照;习题代码可以放开手脚写不同的实现思路。作者在习题入口里通常会加详细的注释,说明这道题考的是什么、他的解法为什么这么选、和参考答案差在哪。
3. 书中算法源码的阅读与验证方法
3.1 从伪代码到可编译 C 代码的映射
严蔚敏书里的算法是用类 C 的伪代码写的,直接抄进编译器大概率报错。这份资源做了一层翻译,把伪代码里的Status、ElemType这些抽象类型落到了具体的typedef上。阅读时建议对照书本,重点看三个地方:函数返回类型是怎么定的、内存分配用的是malloc还是数组、边界条件是怎么处理的。
// 书里常见的 Status 定义,这里落成了 int typedef int Status; #define OK 1 #define ERROR 0 #define OVERFLOW -2 // ElemType 按章节不同会变,线性表里可能是 int,树里可能是结构体 typedef int ElemType; // 以单链表节点为例 typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; // 书上的 InitList 伪代码,落到 C 里要处理二级指针 Status InitList(LinkList *L) { *L = (LinkList)malloc(sizeof(LNode)); if (!*L) return OVERFLOW; (*L)->next = NULL; return OK; }这里的关键差异在于:书里写InitList(&L)时用的是引用语义,C 语言没有引用,必须用二级指针LinkList *L来模拟。这是新手最容易翻车的地方,编译报错expected 'LinkList *' but argument is of type 'LinkList'基本就是这个原因。参数说明上,LinkList *L指向的是头指针的地址,函数内部通过*L修改头指针本身,而不是修改头节点内容。
3.2 用测试用例验证算法正确性
光看代码不够,得跑起来验证。这份资源在每个模块里都带了测试入口,我一般会额外加几个边界用例。以单链表插入为例,至少要测四种情况:空表插入、头部插入、尾部插入、中间位置插入。作者在注释里提到的 bug 触发条件,往往就藏在这些边界里。
// 边界测试:空表插入第一个元素 void test_insert_empty() { LinkList L; InitList(&L); Status s = ListInsert(&L, 1, 100); // 在空表位置 1 插入 assert(s == OK); assert(L->next != NULL); assert(L->next->data == 100); printf("test_insert_empty passed\n"); } // 边界测试:位置越界 void test_insert_out_of_range() { LinkList L; InitList(&L); Status s = ListInsert(&L, 5, 100); // 空表里插位置 5,应该失败 assert(s == ERROR); printf("test_insert_out_of_range passed\n"); }assert在这里是最省事的验证手段,跑一遍全绿说明基本逻辑没问题。如果某个断言挂了,先看是插入位置计算错了,还是malloc失败没处理。作者在说明里特别强调过,书里有些算法对malloc失败的处理是省略的,实际工程里得补上,否则在内存紧张的环境下会直接段错误。
3.3 对照参考答案找差异
这份资源最有价值的部分之一,是它对参考答案的纠错。我的用法是:先自己按书本思路写一遍,再和资源里的代码对比,重点看三处——循环终止条件、指针移动顺序、返回值处理。参考答案里常见的错误包括:循环多走一次导致越界、指针先移动后判断导致空指针解引用、删除节点后忘记free。
// 以单链表删除为例,参考答案里常见的错误写法 Status ListDelete_wrong(LinkList *L, int i, ElemType *e) { LinkList p = *L; int j = 0; while (p->next && j < i - 1) { // 这里 j 的初始值和判断条件容易错 p = p->next; j++; } if (!p->next || j > i - 1) return ERROR; LinkList q = p->next; *e = q->data; p->next = q->next; free(q); return OK; }上面这段的问题在于j的初始值和循环条件配合时,当i = 1时逻辑会出问题。正确的做法是把j初始化为 0,循环条件写成while (p->next && j < i - 1),但删除第一个节点时需要单独处理头指针。作者在资源里对这类边界做了修正,并在注释里写明了原答案错在哪、为什么错。
4. 课后算法设计题的解题思路与代码落地
4.1 从题目描述到算法选型的判断
课后算法设计题大致分几类:顺序表操作、链表操作、栈和队列应用、树遍历、图算法、查找与排序。拿到题目先判断它考的是哪种数据结构的哪种操作,再决定用顺序存储还是链式存储。比如“在单链表中删除所有值为 x 的节点”,考的是链表遍历和节点释放,用链式存储天然合适;而“将两个有序顺序表合并成一个有序顺序表”,考的是双指针归并,顺序存储更直接。
我一般会先写伪代码,把边界条件标出来,再落成 C 代码。资源里的习题答案也是这个路子,每道题前面有简短的分析,说明为什么选这个数据结构、时间复杂度大概是多少。
4.2 典型题目:单链表就地逆置
就地逆置是链表题里的高频考点,也是参考答案容易写错的地方。核心思路是用头插法重建链表,遍历原链表,每摘下一个节点就插到新链表的头部。
// 单链表就地逆置,空间复杂度 O(1) Status ReverseList(LinkList *L) { if (!L || !(*L) || !(*L)->next) return OK; // 空表或单节点直接返回 LinkList p = (*L)->next; // 从第一个数据节点开始 (*L)->next = NULL; // 断开头节点 while (p) { LinkList q = p->next; // 先保存下一个节点 p->next = (*L)->next; // 头插 (*L)->next = p; p = q; // 继续处理下一个 } return OK; }这段代码的关键在q = p->next这一行,必须在修改p->next之前保存后继节点,否则链表就断了。参数LinkList *L是头指针的地址,函数内部通过(*L)->next操作头节点之后的节点。时间复杂度 O(n),空间复杂度 O(1),符合题目对“就地”的要求。作者在注释里还提了一种递归写法,但递归会用到栈空间,严格来说不算 O(1) 空间,面试时如果题目要求就地,还是用迭代稳妥。
4.3 典型题目:二叉树非递归遍历
非递归遍历是树这一章的难点,尤其是后序遍历,比前序和中序都绕。资源里对三种非递归遍历都给了实现,后序用的是双栈法或者标记法。双栈法思路简单:先用一个栈按“根右左”的顺序压栈,弹出的节点再压入第二个栈,最后从第二个栈依次弹出就是“左右根”。
// 二叉树后序遍历,双栈法 void PostOrderTraverse(BiTree T) { if (!T) return; Stack s1, s2; InitStack(&s1); InitStack(&s2); Push(&s1, T); while (!StackEmpty(s1)) { BiTree p; Pop(&s1, &p); Push(&s2, p); // 弹出的节点压入 s2 if (p->lchild) Push(&s1, p->lchild); if (p->rchild) Push(&s1, p->rchild); } while (!StackEmpty(s2)) { BiTree p; Pop(&s2, &p); visit(p); // 访问顺序即为后序 } }双栈法的好处是逻辑清晰,不容易写错;代价是需要两个栈,空间是 O(n) 的两倍。如果题目对空间有要求,可以用单栈加lastVisited指针的写法,但那个版本边界条件多,容易翻车。作者在资源里两种都给了,并说明了各自的适用场景。
4.4 典型题目:图的深度优先与广度优先
图的遍历考的是对邻接矩阵和邻接表两种存储结构的理解。深度优先用递归或栈,广度优先用队列。资源里对两种存储结构分别实现了 DFS 和 BFS,代码结构很规整。
// 邻接矩阵存储的图,DFS 递归实现 void DFS(MGraph G, int v, bool visited[]) { visited[v] = true; visit(v); for (int w = 0; w < G.vexnum; w++) { if (G.arcs[v][w] != INFINITY && !visited[w]) { DFS(G, w, visited); } } } // BFS 用队列实现 void BFS(MGraph G, int v, bool visited[]) { Queue Q; InitQueue(&Q); visited[v] = true; visit(v); EnQueue(&Q, v); while (!QueueEmpty(Q)) { int u; DeQueue(&Q, &u); for (int w = 0; w < G.vexnum; w++) { if (G.arcs[u][w] != INFINITY && !visited[w]) { visited[w] = true; visit(w); EnQueue(&Q, w); } } } }visited数组必须在调用前初始化为false,这是最常见的遗漏。另外邻接矩阵里判断边存在用的是!= INFINITY,如果图里有权值为 0 的边,这个判断依然成立,但如果是用 0 表示无边,就得改成!= 0。作者在注释里专门提醒了这一点,因为不同教材对邻接矩阵的初始化约定不一样。
5. 避坑与排查:那些编译通过但结果不对的情况
5.1 现象:链表操作后打印乱码或崩溃
原因:指针未初始化或释放后继续使用。书里有些算法假设节点已经分配好,但实际调用时如果忘了InitList,头指针就是野指针。另一种情况是删除节点后没有把前驱的next指向后继,导致链表断裂,后续遍历访问到已释放内存。
解决:在InitList里强制把头指针置空,删除操作后立刻free并把指针置NULL。调试时用valgrind跑一遍,能直接定位到非法访问的行号。
5.2 现象:栈和队列操作结果顺序错乱
原因:栈的top指针初始值和入栈出栈顺序搞反了。顺序栈常见的约定是top指向栈顶元素的下一个位置,入栈时先赋值再top++,出栈时先top--再取值。如果搞反了,第一个元素就会出错。
解决:对照书本确认top的约定,在InitStack、Push、Pop三个函数里保持一致。测试时先入栈一个元素再出栈,看结果对不对,再测多个元素。
5.3 现象:树遍历结果缺少节点或重复访问
原因:递归终止条件写错,或者非递归遍历时入栈顺序不对。比如中序遍历非递归实现,如果while条件里漏了|| !StackEmpty(S),遍历完左子树后就退出了,右子树没访问到。
解决:把递归版本和非递归版本对同一棵树跑一遍,结果应该完全一致。如果不一致,先检查非递归的循环条件,再检查入栈和访问的先后顺序。
5.4 现象:排序结果部分有序但整体不对
原因:边界值处理不当,比如快速排序的基准值选取导致分区不平衡,或者归并排序的合并条件写成了<=导致不稳定。书里的排序算法有些是伪代码,落到 C 里时数组下标从 0 开始还是从 1 开始容易混。
解决:统一用 0 基下标,在函数入口处把书里的 1 基逻辑转换过来。测试时用随机数组跑一千次,每次和qsort的结果对比,全一致才算过。
5.5 现象:CMake 编译通过但链接报错
原因:多个模块定义了同名函数或全局变量。C 语言没有命名空间,不同章节里可能都有InitList,如果都放在全局作用域就会冲突。
解决:用static把模块内部的函数限制在本文件,或者给函数加模块前缀。CMake 里也可以用target_include_directories把各模块的头文件目录隔离开,避免互相污染。
6. 进阶用法:把这份资源变成自己的算法练习库
这份资源最容易被低估的地方,是它可以当成一个持续迭代的练习框架。我的习惯是:每学完一章,就在对应模块里新建一个exercise目录,把自己的解法写进去,和作者的答案对比。CMake 里加一行add_executable就能挂上新入口,不用动原有代码。
# 在对应模块的 CMakeLists.txt 里追加自己的练习入口 add_executable(my_singly_linked_list_exercise exercise/my_singly_linked_list_exercise.c src/singly_linked_list.c )这样做的另一个好处是,你可以逐步把书里的算法替换成自己的实现,用同一套测试用例验证。如果某天你的实现和作者的不一致但测试全过,说明你找到了另一种可行解法,这比单纯抄答案有价值得多。
验证方法上,我一般会加一个run_all_tests目标,把所有模块的测试入口串起来,每次改完代码跑一遍,确保没有回归。
# 在 build 目录下依次运行所有测试 ctest --output-on-failure如果工程里配了enable_testing()和add_test(),ctest就能一键跑完所有用例。没配的话,写个 shell 脚本按顺序执行各模块的可执行文件也行。关键是养成“改完必跑”的习惯,数据结构的代码看着简单,指针一错就是段错误,没有测试兜底很容易改出新问题。
从那以后我每次拿到一份老代码,都强制先跑通编译、再跑通测试、最后才动代码。这份资源已经把前两步铺好了,剩下的就是你自己往里填练习。希望帮到你。
本文还有配套的精品资源,点击获取