多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++数据结构算法学习代码编译指南:从解压到跑通

C++数据结构算法学习代码编译指南:从解压到跑通 简介这是一套与《数据结构、算法与应用C语言描述原书第二版》配套的学习代码包面向正在系统学习数据结构与算法的C初学者及考研复试备考人群它可有效弥补教材中大量算法示例只有伪代码、缺乏可运行实现的不足。此压缩包内共有562个文件以200个C源文件、129个头文件为主体另含输入/输出样例文件及Visual Studio工程文件可直接加载调试运行包体大小仅346KB却覆盖了最大利润背包问题、机器车间模拟、最小成本分支限界、最近点对、棋盘覆盖、装载问题等经典算法实例非常适合边读边练便于对照改错。该资源已有325人学习浏览文件按算法模块组织定位清晰能够帮助读者快速找到对应章节的代码实现深化对抽象数据结构和算法思想的理解。1. 这份《数据结构、算法与应用 C语言描述 原书第二版》学习代码先别急着解压很多人下载这份 zip 后第一件事是解压然后面对一堆.cpp和.h文件愣住不知道从哪个文件开始读。这套代码是《数据结构、算法与应用 C语言描述 原书第二版》的配套学习代码覆盖线性表、栈、队列、二叉树、堆、图、排序、搜索等章节每一份代码都对应书里的一个或一组 ADT。它能让你把书里抽象的伪代码变成能编译、能运行、能改参数的真实 C 程序特别适合正在啃这本书的学生、准备算法笔试的开发者以及想快速回顾数据结构实现细节的工程师。但我必须先说一句这份代码不是拿来直接运行的它需要你理解头文件和实现文件的拆分方式并且自己处理编译参数。这篇文章就是把从解压到跑通的完整路径讲清楚。2. 先看清代码的组织方式头文件、实现与测试程序的三角关系2.1 目录结构里最常见的三种文件角色解压之后你通常会看到三类文件混在一起。第一类是头文件后缀是.h负责声明类、模板、函数接口第二类是实现文件后缀是.cpp负责定义成员函数的具体逻辑第三类是测试或示例文件通常是main.cpp或test_xxx.cpp里面有main函数负责创建数据结构对象并调用接口最后把结果打印出来。这三类文件的角色完全不同。头文件是“承诺”告诉你这个类有哪些方法实现文件是“履行”告诉你每个方法到底做了什么测试文件是“验收”告诉你做出来的东西能不能用。我在处理这类学习代码时习惯先按角色把它们分到三个目录里include/放头文件src/放实现demo/放测试程序。但你要注意原书第二版的配套代码大概率是扁平结构也就是所有文件都在同一个目录下这并不影响编译只需要在编译命令里把当前目录加进头文件搜索路径即可。也有一种情况是代码包里已经分好了chapter02、chapter03这样的章节目录每一章下面再放对应的源码。这种结构对学习更友好因为你按章节推进就行。但问题在于不同章节的代码可能会共用同一个公共工具头文件当这个工具头文件放在根目录或者公共目录下时编译器在子目录里找不到它于是报出“找不到头文件”的错误。所以第一步不是急着打开某个.cpp而是先看整体文件列表找到那些名字像util、xcept、compare的公共文件确认它们的位置。我常用的一个查看依赖的命令是这样grep -n #include src/xxx.cpp | head -20这条命令会输出这个实现文件引用了哪些头文件。如果里面有util.h、xcept.h这类公共依赖而它们又不在当前目录下你就需要在编译时用-I参数把公共目录加进去。这一步做完很多莫名其妙的编译错误能消掉一大半。2.2 公共工具依赖为什么有些文件缺了“帮手”就编译不过原书第二版的代码里几乎所有数据结构类都依赖一个公共异常处理机制。比如数组越界时抛出异常、空栈调用top()时抛出异常这些异常类的定义通常会放在一个公共头文件里。如果你的编译命令只加了当前目录作为头文件路径而公共头文件在上一级目录那编译器就会报fatal error: xxx.h: No such file or directory。解决思路很简单先看被包含的头文件在哪里再把这个路径告诉编译器。我一般会这样做find . -name util.h -o -name xcept.h 2/dev/null找到了位置之后在编译命令里增加-I参数指向公共头文件所在目录。如果你发现xxx.cpp里包含的头文件根本不存在于整个代码包中那就要警惕一件事这份代码可能依赖某个外部库或者文件本身是不完整的。遇到这种情况我建议先跳过这个文件去找同章节有main函数的示例因为示例文件才是能跑通的重点。公共依赖还有一个容易被忽视的坑头文件的包含顺序。早期 C 教材的代码里经常能看到#include iostream.h这种写法这是 C 标准化之前的头文件风格。如果你的编译器版本比较新这种头文件已经没有需要改成#include iostream。这个操作我后面在避坑章节里会专门展开。2.3 关键决策先看书还是先看代码拿到这份学习代码后我强烈建议你不要从第一个文件开始顺序读。这个代码包的体量不小从第一个文件读到最后一个文件会很快消耗你的耐心而且很多文件是工具类跟具体数据结构关系不大。正确顺序是先打开原书第二版的目录找到你当前要学的章节再去代码包里找对应的文件。比如你在学“栈”这一章就在代码包里找名字含stack的文件。打开之后先看测试文件里的main函数看它创建了什么对象、调用了哪些方法、打印了什么结果然后再去看对应的头文件和实现文件。这个顺序跟编译器不一样编译器是从头文件开始处理但人脑更适合从“使用场景”倒推回“实现细节”。这种反向阅读的好处非常明显。你从main函数里能看到s.push(5)、s.top()、s.pop()这些调用头脑里就会先有一个“栈该怎么用”的映像然后看实现文件时你会主动想“push 到底做了什么”“top 在空栈时怎么处理”。这种带着问题的阅读比从头顺着读的效率高得多。原书第二版里的代码风格偏“教科书式”大量使用模板和迭代器类型参数多初次接触会有门槛这是正常的不是代码有问题。你只需要抓住一个主干文件精读其他文件作为参考就能逐步建立起对代码结构的整体认知。3. 把代码跑起来命令行、IDE 和 CMake 三种落地路径3.1 命令行编译最小可运行命令与参数说明我收到过不少读者的私信最常见的诉求是“我只想跑通一个示例该怎么办”。对于单个示例命令行编译是最直接的方式。假设你要跑线性表相关示例文件结构大致是include/linearList.h线性表接口声明src/arrayList.cpp基于数组的线性表实现demo/main_arrayList.cpp测试程序入口那么编译命令可以写成这样g -stdc11 -Wall -I include demo/main_arrayList.cpp src/arrayList.cpp -o demo_arrayList我来解释一下这几个参数的含义。-stdc11指定使用 C11 标准代码里使用了模板和迭代器C11 是底线如果你用更新版本如-stdc17大多数情况下也能编译通过。-Wall是打开警告开关它能暴露出拷贝构造函数缺失、类型转换隐患等问题强烈建议保留。-I include告诉编译器去include目录搜索头文件这是上面提到的公共依赖问题的核心解法。最后的-o demo_arrayList指定输出文件名。如果你把头文件和实现文件放在同一个目录下而且所有文件都在当前目录命令可以简化成g -stdc11 main_arrayList.cpp arrayList.cpp -o demo_arrayList这里有一个新手很容易犯的错误只编译了main文件把实现文件漏掉了。比如写成g main_arrayList.cpp -o demo_arrayList这时如果main函数里调用了arrayList的构造函数链接阶段就会报undefined reference。原因很简单编译器在生成可执行文件时需要把main函数和它调用的所有函数的实现绑定在一起没有实现文件这个绑定就无法完成。3.2 用 IDE 打开头文件路径与运行配置命令行适合快速验证但如果你打算长时间在代码里做实验IDE 会更顺手。我用 CLion 和 VS Code 的场景比较多配置思路大同小异核心就两件事指定头文件搜索路径、指定 C 标准。CLion 里如果你直接打开整个代码根目录它默认会尝试加载 CMakeLists如果根目录里没有它可能什么都不做。这时你需要在 CMakeLists.txt 里写入这样一段cmake_minimum_required(VERSION 3.10) project(ds_learning) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) include_directories(include) add_executable(demo_arrayList demo/main_arrayList.cpp src/arrayList.cpp) add_executable(demo_chain demo/main_chain.cpp src/chain.cpp)这段配置做了四件事声明项目名、指定 C 标准为 C11、把include目录加入头文件搜索路径、定义两个可执行文件目标。写完保存后CLion 会重新加载 CMake 项目然后你在右上角选择对应的 target 点运行即可。如果某一行add_executable里引用了不存在的源文件CMake 会直接报错这反而帮你提前发现了依赖问题。VS Code 的做法稍有不同。你需要在.vscode/c_cpp_properties.json里配置includePath并在tasks.json里配置编译命令本质上还是在做同一件事告诉编辑器和编译器去哪里找头文件、用什么标准编译。这里有一个细节VS Code 的 IntelliSense 和实际编译器使用的是两套配置有时候 IntelliSense 不报错但编译报错或者反过来原因就是两套配置不一致。我一般会先用命令行跑通一次再回过来填 IDE 配置这样能少走很多弯路。3.3 用 CMake 批量编译适合一次跑通全部示例当你想把整个代码包的示例文件都编译一遍时手写命令行会非常痛苦。想象一下你有 40 个 demo 文件每个都要写一条g命令哪怕复制粘贴也需要花不少时间。我一般会写一个通用的 CMakeLists用循环批量添加可执行文件。常见的做法是这样cmake_minimum_required(VERSION 3.10) project(ds_all) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) include_directories(include) file(GLOB DEMO_SOURCES demo/*.cpp) foreach(src ${DEMO_SOURCES}) get_filename_component(name ${src} NAME_WE) add_executable(${name} ${src}) endforeach()这段配置里file(GLOB ...)会自动收集demo/目录下的所有.cpp文件然后逐个生成可执行文件。你可能会问这样把所有实现文件都漏掉了链接会失败。没错如果 demo 文件依赖src/下的实现你需要把对应的实现文件也加进来。做法是用另一个file(GLOB ...)收集src/*.cpp然后在add_executable里同时引用两个变量。这里我要特别提醒你一个 CMake 的坑file(GLOB ...)在第一次运行后会自动缓存文件列表。如果你后续在src/里新增了文件CMake 不会自动识别需要重新运行cmake或删除CMakeCache.txt才能生效。这不是原书代码的问题而是 CMake 本身的行为我在实际使用中翻车过几次后来养成了“新增文件后手动重新执行 cmake”的习惯。批量编译的意义在于它能让你快速摸清整个代码包的完整性。如果某个 demo 文件因为缺依赖而编译失败CMake 会在配置或编译阶段直接报错你可以快速定位并修复而不是等学那章时才发现问题。我的建议是拿到代码包的第一时间就做一次全量编译把编译过的问题全部暴露出来再逐个解决。4. 这份代码里最值得精读的几处实现4.1 线性表与链表拷贝控制和迭代器是地基线性表章节的代码是整本书的地基原书第二版通常用arrayList和chain两个类分别演示基于数组和基于链表的实现。读这部分时重点不是看懂每个函数在做什么而是理解为什么一个类需要析构、拷贝构造、赋值操作。如果你只看main函数里的使用很容易觉得“这不就是几个方法吗”但一旦把对象作为参数传递、放进容器拷贝控制的缺失就会立刻引发问题。我建议你做一个动手练习把chain的析构函数单独拎出来看看删除链表节点是用的循环还是递归。如果是循环试着改成递归版本对比两者在链表很长时的栈消耗如果是递归试着改成循环体会指针管理的细节。这个过程能同时复习“递归”“栈帧”“指针”三个知识点。还有一个重点迭代器。原书代码里的链表类一般会配套实现一个迭代器类它的核心是operator和operator*。你可以在测试文件里这样验证#include chain.h #include iostream int main() { chainint c; for (int i 1; i 5; i) c.insert(i, i); for (chainint::iterator it c.begin(); it ! c.end(); it) { std::cout *it ; } std::cout std::endl; return 0; }这段代码创建了一个链表按位置 1 到 5 插入了元素然后用迭代器遍历输出。insert的第一个参数是位置第二个参数是元素值书里对位置参数的定义决定了插入逻辑。你在跑完这段代码后可以试着修改insert的位置参数比如insert(0, i)和insert(6, i)看代码里的异常处理有没有拦下来。这种边界测试能帮你理解位置参数的实际范围。4.2 二叉树与堆递归终止和数组下标是核心难点二叉树章节的代码通常同时提供数组存储和链式存储两种实现。数组存储的堆结构里父子节点的下标关系是关键leftChild 2 * i 1、rightChild 2 * i 2这种关系几乎是常考的考点但代码里的边界判断更值得关注。你在读堆的siftUp和siftDown函数时会看到一些形如while (currentNode ! root)或while (currentNode heapSize / 2)的条件这些条件决定了“当前节点是否还有子节点”“是否已经到达根节点”。我强烈建议你在跑堆排序时做一次“稳定性实验”。默认情况下堆排序是不稳定的如果你有两个相同的元素它们的相对顺序在排序后可能会发生变化。你可以构造一个包含相同元素的数组在排序后输出元素的位置观察它们是否保持了原顺序。这种实验比单纯看代码更能让你记住不稳定排序的行为特征。另外二叉树遍历的代码非常依赖递归的终止条件。书里代码通常用nullptr作为空子树标记递归函数的开头一般会先判断当前节点是否为空。如果你不小心把递归函数的参数传成一个空指针而不加判断程序会直接崩溃。我在读代码时养成了一个习惯看到递归函数先找“递归终止条件在哪一行”再找“递归调用是怎么缩小问题规模的”这两个问题弄清楚了这个函数就读懂了。4.3 图算法部分邻接表和邻接矩阵的选型博弈图的实现文件是这份代码里最复杂的部分之一因为它通常同时提供邻接矩阵和邻接表两套结构。邻接矩阵用二维数组存储代码直观判断两个顶点是否相邻只需查一个下标但空间复杂度是 O(V^2)邻接表用vectorlist或类似结构存储空间更省但代码读起来更绕。书里这部分代码的典型特征是用模板表示顶点类型用int表示边的权重。我在读这段代码时会用一个实际的图跑一遍最短路径算法。比如书上常用的例子手动构造 5 个节点、7 条边的图分别用邻接矩阵版和邻接表版跑同样的算法然后打印两种结构下的内存估算和运行耗时。这里我一般会用clock()函数来粗略测一下#include iostream #include ctime // 假设 adjacencyMatrix 和 adjacencyList 是两个图对象 // 在算法调用前记录时间调用后计算差值如果你的代码里有两种图结构你可以在演示范例那里临时切换一下用同样的图数据分别跑一遍。对比后你会发现当顶点数量增加时邻接表的性能优势主要体现在空间上而邻接矩阵在小规模图上因为缓存友好反而可能更快。这个结论不是从书本里背出来的而是你在自己的机器上跑出来的印象会深得多。4.4 排序与搜索把比较器和模板参数拆开理解排序部分在原书第二版里以模板函数为主比如quickSort、mergeSort这类函数它们的共同点是接收一个数组指针和一个比较器。比较器默认是升序但你可以传入自定义的less或greater函数对象改变排序方向。我一般会尝试给mergeSort传一个lambda表达式让它按绝对值大小排序#include algorithm #include vector #include iostream int main() { std::vectorint data {3, -1, 2, -4, 5}; std::sort(data.begin(), data.end(), [](int a, int b) { return std::abs(a) std::abs(b); }); for (int v : data) std::cout v ; std::cout std::endl; return 0; }这段代码用std::sort演示了比较器的作用lambda表达式按绝对值比较两个元素输出结果是按绝对值升序排列。你可以把这段逻辑迁移到书中的模板排序函数上观察自定义比较器和默认比较器的差异。这个技巧不仅是调试工具还是面试中常考的“自定义排序规则”的落地方案。5. 编译与运行避坑指南从“一堆报错”到“跑通一个 Demo”的排查路径5.1 找不到头文件no such file or directory现象编译时报fatal error: xxx.h: No such file or directory然后整个编译过程中断后面所有代码都不会被检查。原因编译器在当前目录以及你指定的所有头文件搜索路径里都没有找到被#include的头文件。这通常是因为文件在另一个子目录而你没有用-I参数指定路径。解决先用find . -name xxx.h定位头文件的实际位置然后在编译命令里加上-I参数。如果你的头文件和源文件分散在不同目录我建议把根目录作为统一搜索起点写成-I .这样不管头文件在哪个子目录都能被找到。这里面有一个细节-I参数可以多次指定比如-I include -I common -I .编译器会按顺序搜索这些目录。执行完修复后再跑一次编译命令确认报错消失。5.2 链接错误undefined reference 到某个函数现象编译阶段没有报错但链接阶段报undefined reference to后面跟着一个完整的函数名。很多人会被这个错误吓到因为它不像编译错误那样明确指出是哪一行代码的问题。原因最常见的情况是只编译了main文件没有把实现文件一起交给编译器。编译器在生成可执行文件时需要把main函数里调用的每个函数都找到对应的机器码找不到就报“未定义引用”。另一个可能存在的原因是类模板的实现写在.cpp文件里而没有在头文件里显式实例化导致其他文件无法找到模板代码。解决把实现文件追加到编译命令里例如main.cpp src/arrayList.cpp一起编译。如果问题出在模板上更稳妥的做法是把模板的声明和定义全部放在头文件里或者在使用模板的文件里显式实例化模板。这里我推荐前者因为学习代码的目标是快速跑通不是追求编译速度。5.3 老代码遇上新编译器iostream.h 这类旧头文件现象编译报错提示找不到iostream.h、stdlib.h等头文件或者using namespace std这几个词出现的位置有问题。新手看到这种错误最容易失去耐心因为看起来代码本身没有逻辑错误。原因原书第二版沿用了早期 C 教材的写法部分代码使用iostream.h这种非标准头文件。新版编译器遵循 C 标准只提供iostream、cstdlib等头文件不带.h。解决把#include iostream.h改成#include iostream把#include stdlib.h改成#include cstdlib并在用到std::命名空间的地方加using namespace std;。如果你的代码里同时出现了#include iostream.h和#include iostream先统一把所有旧的.h头文件替换掉再处理string.h为string。这个操作是纯文本替换但如果量太大我建议用sed命令批量处理sed -i s/#include iostream.h/#include iostream/g src/*.cpp demo/*.cpp这条命令把每个.cpp文件里的旧式头文件引用改成新式。替换后重新编译大概率能消除这一类报错。5.4 析构函数崩溃double free 或段错误现象程序能正常运行但在main函数返回时崩溃终端输出double free or corruption或者直接用 gdb 跑时能看到SIGSEGV信号。这是最让人头疼的报错因为它出现在“最后一步”破坏整个跑通的成就感。原因类里含指针成员但没有实现拷贝构造函数和赋值运算符。默认的浅拷贝让两个对象共享同一块堆内存其中一个对象析构时释放了内存另一个对象析构时再次释放同一块内存导致double free。解决为这类数据结构类显式实现深拷贝或者在不需要拷贝的场景下把拷贝构造和赋值操作声明为 delete。对于学习代码我不建议在这上面花太多时间先注释掉相关代码调用让程序跑通即可。等你理解了拷贝控制的原理再回过头来补实现。判断一个类是否需要拷贝控制标准很简单类里有裸指针成员或显式new的成员就一定要实现三法则。5.5 vector 的代理对象陷阱现象没有编译错误也没有崩溃但代码行为完全不符合直觉。比如你对vectorbool的某个元素取引用然后打印它的地址得到的地址指向一个代理对象而不是真正的 bool 值。原因C 标准对vectorbool做了特化每个元素只占用 1 位无法直接返回bool所以返回的是一个代理对象这个代理对象的行为在某些场景下和普通 bool 引用不一致。解决如果代码里有类似vectorbool visited(10, false)这种容器需要频繁读写元素建议改成dequebool或vectorchar。原书第二版的图的实现代码里如果用到了 visited 标记数组它可能已经选择vectorbool。你在跑通示例时如果发现 DFS 或 BFS 的结果不对可以优先检查 visited 数组的类型。这个坑很隐蔽但遇到一次后以后看到vectorbool就会多留一个心眼。6. 把学习代码变成自己的调试工具三个能立刻上手的验证技巧第一个技巧是“用断言替换输出验证”。原书第二版的示例代码习惯用cout打印结果跑通了还要人眼去看输出对不对。你可以把关键位置的输出改成assert比如在栈的push之后检查top()是否等于压入的值#include cassert // 栈初始化后 s.push(10); assert(s.top() 10); assert(s.size() 1);这样每次改动代码后运行一遍失败时能够精确定位到某个具体断言省去不少排查时间。第二个技巧是给边界情况加测试空表、单元素、满栈这些场景书里示例不一定覆盖但面试和考试常问。我一般会在main函数里多加几行对空容器的调用观察异常处理是否生效比如对空栈调用pop()看代码是抛异常还是直接崩溃。第三个技巧是用std::chrono做微基准测试对比排序和查找算法的实际表现。你可以在代码里加一个计时器分别跑排好序的数组和逆序数组的快排感受平均复杂度和最坏复杂度的差距。这些技巧听起来简单但坚持用下来你对代码的理解会从“能跑”升级为“能改、能控”。我自己在学图算法时就因为没检查vectorbool这个坑花了整整一个晚上定位 DFS 结果为空的诡异现象后来换了vectorchar才跑通。那次的教训让我明白学习代码只是半成品它需要你亲手把它变成可靠的工具。希望这份避坑经历能帮到你也希望你在啃下这本书时少走弯路。本文还有配套的精品资源点击获取
返回列表