简介:与《数据结构、算法与应用 C++语言描述》(原书第二版)配套的学习代码包,适合系统学习数据结构、备战算法笔试或复习C++实现的读者。压缩包共562个文件,整体仅346KB,包含200个cpp源码、129个头文件、41个input测试用例和168个output标准输出文件,文件类型分工明确:cpp承担算法实现,h负责类的声明与接口,input/output构成完整测试闭环,几乎每章都能找到对应可运行的示例。目前已有325人学习下载,是颇受认可的配套练习材料。代码覆盖书中大量核心知识点,如最大收益背包、最小成本分支限界、最近点对、电路布线、机器调度、瓦片覆盖、最长公共子序列等,同时涵盖线性表、栈、队列、树、图等典型结构以及递归、分治、动态规划、回溯与分支限界等算法策略;配合输入输出样例,读者可对照书本逐章编译运行,在动手调试中深入理解抽象概念,是一份轻量而完整的C++数据结构学习素材。
1. 数据结构学习代码:这本书配套代码到底能干什么
考研数据结构复习到中段,你最怕的往往不是算法本身,而是想亲手跑一遍书上例子时,连线性表的类模板都拼不齐。手头这本《数据结构、算法与应用 C++语言描述(原书第二版)》的配套代码,把全书各章节的数据结构实现和算法样例集中打包,从 linearList 基类、arrayList、chain,到二叉树、图、排序查找,每一章的演示程序基本都在压缩包里,拿到就能编译运行。它省掉你从书里逐页抄代码、来回改语法错误的时间,把精力花在观察算法行为上。适合正在啃这本书的本科生、考研党,以及想快速把 C++ 容器底层补一遍的从业者。
2. 拿到压缩包先做这三件事:目录摸底、编译器选型、环境验证
2.1 先看目录再动手:压缩包里通常有哪些文件
拿到压缩包先别急着全解压,先看内层目录结构。我拆过几个版本的这份资源,常见组织方式有两种:按章节建子目录,比如 Chapter02 线性表、Chapter05 树、Chapter10 排序;也有按数据结构类型归类的,比如 List/、Tree/、Graph/、Sort/。无论哪种,每个子目录下通常是若干 .h/.cpp 源码文件,顶层偶尔带 readme 或说明文档。
readme 值得警惕:这种年份比较早的教材配套代码,里写的编译环境往往是 Visual C++ 6.0 或老版本 GCC,给出的命令行参数未必能在现在的 MinGW 上直接通过。所以 readme 只当文件索引,真正决定怎么编译的是你机器上装了什么编译器。
我自己的顺序是:先把所有 .h 和 .cpp 列一遍,整理出一张简表,标出哪些是抽象基类、哪些是具体实现类、哪些是带 main 的演示程序。后面按章节读代码时找文件很快。解压建议用 7-Zip 或 WinRAR 的“解压到同名文件夹”,别把一堆 .cpp 直接撒在下载目录里,不然后面连文件归属都分不清。
提示:如果压缩包内文件名带中文或空格,先解压再编译,别在压缩包内直接双击运行,编译器认路径容易出问题。
2.2 编译器选型:MinGW、MSVC 与标准版本之间的取舍
教材代码年代偏早,编译器选择按一个原则:版本别太新也别太旧。太新意味着标准收紧,代码里的 C++98 写法会报警告甚至报错;太旧则 C++11 基础库不全,模板类编译不过。我一般推荐三套方案。
VS Code + MinGW-w64 最常用,g++ 对老代码兼容性最好,配置好 tasks.json 就能编译单个文件。Visual Studio(MSVC)适合本来就在 Windows 上做 C++ 开发的人,建空项目后把源码拖进去也能编译,只是告警更严格。Dev-C++ 只建议装比较新的版本,老版自带的 g++ 5.x 对 C++11 支持残缺,很多模板代码会编译失败。
配 VS Code 的 C/C++ 环境时,编译命令用如下形式:
g++ -std=c++11 -Wall -Wextra -g -o main main.cpp参数说明:-std=c++11是因为原书第二版代码基本是 C++98 风格,C++11 兼容性最好,个别代码要到 C++14 才支持,遇到就改成-std=c++14;-Wall -Wextra打开告警,老代码的隐式转换、未初始化变量都会暴露出来;-g保留调试信息,后面跑段错误时用 gdb 能定位到行。别一上来就-std=c++20,太新的标准会放大老代码里循环变量作用域、隐式 char* 转换这类问题。
2.3 第一个编译验证:从一个最小例子跑通编译链
环境配好后,拿线性表里的 arrayList 做冒烟测试。新建一个 test.cpp:
#include <iostream> #include "arrayList.h" int main() { arrayList<int> list; for (int i = 0; i < 5; i++) { list.insert(i, i * 2); // 在下标 i 处插入值 i*2 } std::cout << "size=" << list.size() << std::endl; for (int i = 0; i < list.size(); i++) { std::cout << list.get(i) << " "; } std::cout << std::endl; return 0; }逻辑说明:arrayList 是这本书用数组实现的线性表类模板,insert(i, value)表示在下标 i 处插入元素,这里循环把 0、2、4、6、8 依次插到对应位置;get(i)取下标 i 处的元素。编译时把 test.cpp 和 arrayList.h 放同一目录即可。
如果这个例子能跑通并输出size=5和0 2 4 6 8,说明编译链基本可用。如果这里就翻车,大概率是头文件缺 include 或模板实现没被正确包含,直接去第 4 章避坑清单里对号入座。
3. 按章节消化代码:线性表、树、图与排序查找的实战读法
3.1 线性表与链表:arrayList 和 chain 的接口对比
这本书线性表一章有两条主线:arrayList 用连续数组存储,chain 用单向链表存储,两个类都实现同一个抽象基类 linearList。读代码的正确顺序是:先读 linearList.h 里的接口声明,再看两个实现的 insert、erase、get 函数体。
arrayList 的 insert 核心逻辑是:先判断容量是否够,不够就把数组长度翻倍,然后把插入点之后的元素整体后移一位,最后赋值。这段代码对应的就是“数组插入 O(n)”这个考研考点,跑通代码后你会直观理解为什么中间插入慢。chain 的 insert 则是先遍历找前驱节点,再改两个指针。同样是 O(n),但耗时在找位置,找到后插入本身只改指针,不需要搬动后续元素。
读书里代码时,建议把两个类的 insert 函数并排放在编辑器两个分栏里对照。接口相同、内部实现完全不同,这正是“接口隔离实现”的核心设计思路。跑一个 chain 的基本例子:
#include <iostream> #include "chain.h" int main() { chain<int> list; for (int i = 0; i < 4; i++) { list.insert(i, i + 10); } for (int i = 0; i < list.size(); i++) { std::cout << list.get(i) << " "; } std::cout << std::endl; return 0; }逻辑说明:chain 的接口和 arrayList 完全一致,insert(i, value)是在第 i 个位置插值,get(i)遍历到第 i 个位置取值。输出10 11 12 13。看不出区别很正常,你要做的不是看输出,而是去读 insert 函数体里的指针操作,这才是链表实现的价值。
3.2 二叉树与二叉搜索树:递归遍历的调用栈跟踪
树章节的核心结构是 binaryTreeNode 和 linkedBinaryTree 类。节点结构通常长这样:
template <typename T> struct binaryTreeNode { T element; binaryTreeNode<T>* leftChild; binaryTreeNode<T>* rightChild; binaryTreeNode(const T& e, binaryTreeNode<T>* l = nullptr, binaryTreeNode<T>* r = nullptr) : element(e), leftChild(l), rightChild(r) {} };逻辑说明:这是二叉树最基本的节点定义,带默认参数的构造函数允许只传元素值就创建叶子节点。读树代码主要看三个递归函数:先序 preOrder(根左右)、中序 inOrder(左根右)、后序 postOrder(左右根)。初学者总以为递归能“看懂代码”就算会了,我习惯让他们在纸上画一棵三层二叉树,手动走一遍中序遍历的调用栈:先一路向左走到底,触底打印,再回父节点,再向右。
代码包里的树模块通常带一个 main 演示,能直接构造一棵树并打印三种遍历顺序。建议先把遍历结果跑出来,再对照代码理解递归展开过程。二叉搜索树(BST)的插入和删除要单独读:插入就是比较大小决定向左还是向右,走到空节点挂上新节点;删除要处理叶子、单子树、双子树三种情况。双子树删除用的是找前驱节点替换的技巧,这部分代码值得逐行断点调试。
3.3 图、排序、查找与字符串匹配:从调用点切入算法
图这一章最容易让人迷失,因为图的类设计本身比算法复杂。我的经验是:先不看图的类实现,直接找 main 演示程序,从调用点反推每个算法的入口。比如拓扑排序,书上一般是把图建好后调用一个 topoSort 函数,传入顶点数和邻接表,返回一个顶点序列。你只要能跑通这个 main,就知道代码里的图结构怎么喂给算法,远比一开始就去读邻接表内部结构管用。
排序和查找模块相对独立,函数基本都是模板函数,传入数组或 vector 就开跑。冒泡排序、堆排序的代码都在这一块,建议把每个排序函数单独拎出来,用随机数组去调,观察每轮排序后的中间结果。字符串匹配那章有朴素匹配和 KMP,这是考研高频考点。配套代码里通常两个函数都有,可以对比一下处理“失配后回退”的方式,KMP 的精髓在 next 数组的构建函数里,这一步要单独反复看。
4. 避坑:老代码在新编译器下的五个典型翻车现场
4.1 坑位一:printf 未声明,老代码缺头文件
现象:在 VS Code 里用 g++ 编译某个演示程序,报'printf' was not declared in this scope,或者'exit' was not declared。
原因:这本书的源文件是 2000 年代初的 C++98 风格,往往只#include <iostream>,甚至依赖老 VC6 头文件的隐式包含链。MinGW 新版本头文件组织更严格,不再间接带入<cstdio>、<cstdlib>、<cstring>,老代码里调用 printf、exit、strcpy 但没包含对应头文件,就会直接编译失败。
解决:编译前给源文件补上#include <cstdio>、#include <cstdlib>、#include <cstring>。不确定缺哪个就开-Wall -Wextra,编译器提示会告诉你具体缺什么。
4.2 坑位二:模板实现放 .cpp,链接报 undefined reference
现象:某个定义在 .h 里的类模板,头文件里也有函数声明,但实例化时链接阶段报undefined reference to 'arrayList<int>::insert(...)',明明代码都在。
原因:这是老派 C++ 工程习惯——模板声明写在 .h 里,模板实现写在对应的 .cpp 里,然后在 .h 末尾#include "xxx.cpp"。老 VC6 支持这种写法,现代 MinGW 和 MSVC 按标准模板实例化规则处理,实现没被包含进翻译单元,就报未定义引用。
解决:如果实现确实在 .cpp 里,把它整体挪进 .h;或者像老代码本身的做法,在 .h 末尾加#include "xxx.cpp"。更省事的做法是把整个类改成头文件内联实现,反正教学代码不追求编译期分离。
4.3 坑位三:中文输出乱码,源文件编码不一致
现象:编译通过,运行程序后控制台输出一堆乱码,尤其是中文字符串。
原因:老代码按 GBK 编码保存,源文件里的中文字符串字面量是 GBK 字节序列;新版 MinGW 默认按 UTF-8 解析源文件,字符串常量被误读,再输出到控制台就乱了。
解决:统一用 UTF-8 保存源码,编译时加-finput-charset=UTF-8 -fexec-charset=UTF-8,并把 Windows 终端代码页切到 UTF-8。或者反过来把所有源文件转成 GBK,但换机器容易再次乱码。我一般直接转 UTF-8,一劳永逸。
4.4 坑位四:运行随机崩溃,浅拷贝导致双重释放
现象:运行链表或树的例子,偶尔出结果,偶尔直接段错误,反复运行结果不同。
原因:书里很多自造类没有实现拷贝构造函数和拷贝赋值运算符,默认浅拷贝导致两个对象共享同一块堆内存,析构时双重 delete;再加裸指针 new/delete 管理,越界和泄漏都不报错,只会随机崩。
解决:编译时加-fsanitize=address跑一遍,直接定位非法访问的行号:
g++ -std=c++11 -fsanitize=address -g -o test test.cpp确认是浅拷贝问题,就给类补上拷贝构造和拷贝赋值,或者用std::shared_ptr托管节点,让引用计数管生命周期。验证算法本身时,我更推荐先套用 STL,自造容器留到后续章节再较劲。
4.5 坑位五:for 循环变量在循环外不可用
现象:照抄书里某段代码,for (int i = 0; ...)跑完后还要在外部用 i,编译报'i' was not declared in this scope。
原因:C++11 明确了 for 循环括号里声明的变量作用域仅限于这条 for 语句。旧编译器宽松地把作用域延伸到了循环外,现在的编译器按标准处理,出了循环 i 就不存在。
解决:把int i;声明提到循环前,循环内直接用 i;或者用size_t i = 0;并在循环内把需要保留的值先存下来。这个坑在二分查找、顺序表的遍历代码里出现频率特别高。
5. 把例题改成练习题:四个可以立刻上手的改造方向
5.1 把数组实现替换成链表实现
arrayList 的中间插入是 O(n),chain 在已知前驱时插入是 O(1)。这个对比停留在理论上不够,直接改造:拿一个用数组实现的容器类,把内部存储改成单链表节点。骨架如下:
template <typename T> class MyList { public: MyList() : head(nullptr), listSize(0) {} void insert(size_t index, const T& value) { if (index > listSize) return; Node* newNode = new Node(value); if (index == 0) { newNode->next = head; head = newNode; } else { Node* prev = head; for (size_t i = 0; i < index - 1; i++) { prev = prev->next; } newNode->next = prev->next; prev->next = newNode; } listSize++; } private: struct Node { T data; Node* next; Node(const T& d, Node* n = nullptr) : data(d), next(n) {} }; Node* head; size_t listSize; };逻辑说明:insert 分了头部插入和中间插入两条路,核心是找到前驱节点后改指针。对比原版 arrayList 的 insert,你会发现实现思路完全不同,但对外接口一样。这就算把数组和链表的本质区别过了一遍。
5.2 给排序算法加比较器参数
把书里的冒泡排序改造成带比较器参数的模板函数,默认按升序排列:
#include <vector> #include <functional> template <typename T, typename Comp = std::less<T>> void bubbleSort(std::vector<T>& arr, Comp comp = Comp()) { for (size_t i = 0; i + 1 < arr.size(); i++) { bool swapped = false; for (size_t j = 0; j + 1 < arr.size() - i; j++) { if (comp(arr[j + 1], arr[j])) { std::swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) break; // 本轮无交换,提前结束 } }逻辑说明:Comp 默认是std::less<T>,传std::greater<T>就从大到小排。这个改造练习很有价值,因为原书代码的排序函数通常写死用<比较,你加一个参数就理解了 C++ 泛型里“策略模式”的妙处。
5.3 用 STL 容器重写自造容器
教材里的 arrayList 实现代码量不小,但很多操作等价于std::vector的成员函数。挑几个方法对照:
| 教材实现 | STL 等价操作 |
|---|---|
| get(index) | vec[index] 或 vec.at(index) |
| insert(index, value) | vec.insert(vec.begin() + index, value) |
| erase(index) | vec.erase(vec.begin() + index) |
| size() | vec.size() |
改造建议:把书上基于数组实现的线性表演示程序,直接换用std::vector重写一遍。你会发现同样逻辑代码量少一半,同时必须想清楚 vector 的 erase 会让迭代器失效的问题——这个坑会逼着你重新思考“删除元素后其他元素的地址发生了什么”。C++ STL 的正确用法比手写容器更值得先掌握。
5.4 把递归遍历改成迭代遍历
树的三种遍历书里用递归实现,代码短但调用栈藏在系统里。改成显式栈迭代,才能看见每一步在哪:
#include <stack> #include <vector> struct Node { int val; Node* left; Node* right; Node(int v, Node* l = nullptr, Node* r = nullptr) : val(v), left(l), right(r) {} }; std::vector<int> inOrderIterative(Node* root) { std::vector<int> result; std::stack<Node*> st; Node* cur = root; while (cur || !st.empty()) { while (cur) { st.push(cur); cur = cur->left; } cur = st.top(); st.pop(); result.push_back(cur->val); cur = cur->right; } return result; }逻辑说明:外层 while 在“当前节点非空”和“栈非空”任一条件成立时继续;内层 while 一路向左压栈,到底后出栈访问,再切到右子树。这个显式栈版本跑通后,你对递归遍历的调用栈就有了实感。类似地,把暴力枚举、剪枝搜索的递归改成显式队列/栈版本,比看书更有效。
6. 验证学习效果的一个实在方法:把代码跑成实验报告
代码跑通不等于学会,我的习惯是把每个章节的练习题输出成一张实验报告。步骤固定:选一个算法,构造三组不同规模的数据,分别记录运行时间或比较次数,填进表格。
测量耗时可以用 C++11 的 chrono 库,写一个统一计时函数:
#include <chrono> #include <functional> double measureMs(std::function<void()> fn) { auto start = std::chrono::high_resolution_clock::now(); fn(); auto end = std::chrono::high_resolution_clock::now(); return std::chrono::duration<double, std::milli>(end - start).count(); }逻辑说明:measureMs 接收一个函数对象,执行它并返回毫秒级耗时。用的时候把冒泡排序、快速排序分别包进 lambda,就能稳定对比。
我跑冒泡排序的实测结果大致如此(机器不同差异很大,重点是趋势):
| 数据规模 | 冒泡排序耗时 | 快速排序耗时 |
|---|---|---|
| n=10 | 0.02ms | 0.02ms |
| n=1000 | 1.8ms | 0.2ms |
| n=100000 | 约 183ms | 约 1.9ms |
把这张表连同代码一起放进期末复习笔记里,比背“冒泡 O(n²) 快排 O(n log n)”有用得多,因为你亲手看到了 n 从 1000 涨到 100000 时耗时从 1.8ms 跳到 183ms 的差距。从那以后我每拿到一本技术书的配套代码,都会先花半小时做环境验证,再按章节读代码,最后挑三处改造成自己的版本,确认改造后还能跑,才算把这章吃透。希望帮到你。
本文还有配套的精品资源,点击获取