首页 / 资讯中心 / 文章详情

数据结构C++第二版配套代码:从编译到避坑的完整实践指南

数据结构C++第二版配套代码:从编译到避坑的完整实践指南 ★ FEATURED ARTICLE
简介这份资源是《数据结构、算法与应用 C语言描述》原书第二版的配套学习代码包面向正在学习数据结构与算法的高校学生、考研备考者以及希望用 C 巩固算法基础的开发者。它解决的是理论学习与动手实践脱节的问题读者可对照教材章节直接运行、调试代码加深对抽象数据类型的理解。压缩包共 562 个文件约 346KB其中 200 个 cpp 源文件与 129 个头文件构成核心实现41 个 input 与 168 个 output 文件提供测试数据与预期结果另有少量工程文件与说明文档便于在 Visual Studio 中直接编译运行。内容覆盖背包问题、机器车间模拟、最近点对、布线算法、棋盘覆盖等经典案例涉及回溯、分支限界、贪心与图算法等主题。目前已有 325 人学习下载适合作为课程实验、课后练习与算法复现的参考素材。1. 数据结构学习代码从一份 C 描述的第二版配套源码说起很多人学数据结构卡住的地方不是看不懂概念而是看完书上的伪代码自己动手写不出能跑的程序。数组、链表、栈、队列、树、图这些结构书上画得清清楚楚可一到实现指针怎么连、内存怎么管、边界怎么处理全是问题。这份「数据结构、算法与应用 C语言描述 原书第二版」的配套学习代码解决的正是这个断层——它把书里每一章的核心结构都用可编译的 C 代码落了一遍让你能对着跑、对着改、对着调。适合两类人一是正在啃数据结构教材、需要可运行参照的在校学习者二是工作几年后想回头补算法基础、但不想再看纯理论描述的一线开发者。下面我按自己复现这类代码包的经验把选型、环境、逐章实现和踩坑点讲清楚。2. 先搞清楚这份代码包的组织方式与编译环境拿到一个学习代码压缩包最忌讳上来就逐个文件打开看。正确做法是先摸清目录结构再确定编译方式最后挑一个最小可运行单元验证环境。这一步做扎实后面能省掉大量「为什么编译不过」的时间。2.1 目录结构通常怎么分按章节还是按数据结构这类配套代码一般有两种组织方式。第一种是按书本章节分目录比如chapter02、chapter03每个目录里放该章涉及的所有结构实现和测试代码。第二种是按数据结构类型分目录比如array、linkedlist、stack、queue、tree、graph。两种方式各有优劣按章节分跟书对照方便但同一个结构可能在多章重复出现按结构分复用性好但跟书的对应关系需要自己建索引。我一般先执行一条命令看整体布局# 查看压缩包解压后的目录树只看两层避免输出过长 find . -maxdepth 2 -type d | sort如果输出里出现大量chapterXX形式的目录说明是按章节组织如果出现的是结构名目录说明是按类型组织。这一步决定了你后面找代码的方式——按章节组织的直接翻到对应章按类型组织的需要先看书目录再定位。2.2 编译方式的选择单文件编译还是 Makefile 统一构建学习代码包通常不会提供复杂的构建系统常见的是每个.cpp文件独立编译或者提供一个简单的Makefile。先检查有没有Makefile或CMakeLists.txt# 查找构建配置文件 find . -maxdepth 2 -name Makefile -o -name CMakeLists.txt -o -name *.mk如果有Makefile直接make通常就能编译全部如果没有就需要手动编译单个文件。手动编译时注意很多学习代码会把类的声明放在.h文件、实现放在.cpp文件编译时需要同时指定# 编译一个包含头文件和实现文件的单元 g -stdc11 -Wall -g -o test_list list.cpp list_test.cpp这里-stdc11是因为第二版教材的代码大量使用了模板和 STLC11 是底线-Wall打开所有警告学习阶段警告往往就是潜在 bug-g保留调试信息方便用 gdb 单步跟踪指针操作。2.3 用最小可运行单元验证环境是否就绪不要一上来就编译最复杂的图算法。先找一个最简单的结构比如数组或单链表编译运行看结果。以单链表为例通常会有三个文件chain.h类定义、chain.cpp实现、chain_test.cpp测试。编译运行# 编译并运行单链表测试 g -stdc11 -Wall -g -o chain_test chain.cpp chain_test.cpp ./chain_test如果输出符合预期比如打印出插入、删除后的链表元素说明编译环境没问题。如果报错先看是不是缺少头文件路径用-I指定# 头文件在 include 目录时指定搜索路径 g -stdc11 -Wall -g -I./include -o chain_test chain.cpp chain_test.cpp这一步通过后再逐个编译其他结构。遇到编译错误不要慌学习代码包里最常见的错误是旧版 C 语法与新编译器不兼容比如iostream.h这种老写法改成iostream加using namespace std;即可。3. 线性结构数组、链表、栈和队列的 C 实现要点线性结构是整本书的地基也是代码包里最应该先跑通的部分。很多人觉得数组和链表太简单跳过不看结果后面学树和图的存储时连基本的节点定义和内存管理都写不利索。这一章把四个核心线性结构的实现要点和参数选择讲透。3.1 数组类为什么教材里的 arrayList 比裸数组多了一层封装教材里的数组结构通常叫arrayList或arrayListWithIterator它比裸数组多了几个关键能力自动扩容、越界检查、迭代器支持。核心成员变量一般有三个T* element动态数组指针、int arrayLength当前容量、int listSize实际元素个数。插入操作的实现逻辑是先检查是否需要扩容如果需要申请一块更大的内存把原数据拷贝过去释放旧内存然后把插入位置之后的元素整体后移一位写入新元素listSize加一。删除操作相反把删除位置之后的元素整体前移一位listSize减一。关键参数是扩容倍数。常见做法是扩容为原来的 2 倍也有用 1.5 倍的。2 倍扩容的均摊时间复杂度是 O(1)但内存浪费可能达到 50%1.5 倍内存利用率更高但扩容次数略多。学习代码里一般用 2 倍因为计算简单。// 数组扩容的核心逻辑 templateclass T void arrayListT::resize(int newLength) { T* newElement new T[newLength]; // 申请新内存 int num min(listSize, newLength); for (int i 0; i num; i) newElement[i] element[i]; // 拷贝原有数据 delete[] element; // 释放旧内存 element newElement; // 指向新内存 arrayLength newLength; }这段代码里min(listSize, newLength)是为了处理缩容的情况——当新容量比当前元素个数还小时只拷贝能放下的部分。学习阶段建议把扩容和缩容分开写避免逻辑混淆。3.2 链表类单向链表、双向链表和循环链表的节点定义差异链表的节点定义是理解所有变体的基础。单向链表的节点只有data和next两个成员双向链表多一个prev循环链表则是把尾节点的next指向头节点或者用带头节点的循环结构。教材里常见的chain类使用带头节点的单向链表头节点不存数据只用来简化插入和删除的边界处理。插入操作在指定位置插入时需要先定位到前驱节点// 在索引 theIndex 处插入元素 templateclass T void chainT::insert(int theIndex, const T theElement) { if (theIndex 0 || theIndex listSize) throw illegalIndex(插入位置非法); // 定位到 theIndex-1 位置的前驱节点 chainNodeT* p firstNode; for (int i 0; i theIndex - 1; i) p p-next; // 插入新节点 p-next new chainNodeT(theElement, p-next); listSize; }这里chainNodeT(theElement, p-next)是节点的构造函数第二个参数指定新节点的next指向。注意插入位置为 0 时循环不执行p就是头节点逻辑正确。删除操作类似需要先定位到前驱节点然后delete被删节点。双向链表的插入和删除需要同时维护next和prev两个指针顺序不能错。常见错误是先改prev再改next导致指针丢失。正确顺序是先让新节点的next和prev指向正确位置再改前驱的next和后继的prev。3.3 栈和队列数组实现与链表实现的取舍栈和队列是操作受限的线性表实现上既可以基于数组也可以基于链表。教材里通常两种都提供让你对比。数组实现的栈用一个stackTop指针记录栈顶位置入栈时stackTop加一出栈时减一。关键点是栈满的判断stackTop stackSize - 1。如果栈满还要入栈就需要扩容扩容逻辑和数组类一样。链表实现的栈用头插法入栈就是在头部插入节点出栈就是删除头节点。时间复杂度都是 O(1)但链表实现不需要预先分配容量适合元素个数不确定的场景。队列的数组实现要处理「假溢出」问题——当rear到达数组末尾但前面还有空位时需要用循环队列。循环队列的关键是front和rear的更新公式// 循环队列的入队操作 templateclass T void queueT::push(const T theElement) { if ((rear 1) % arrayLength front) throw queueFull(队列已满); element[rear] theElement; rear (rear 1) % arrayLength; // 循环后移 }这里(rear 1) % arrayLength front是判断队列满的条件牺牲一个存储单元来区分空和满。链表实现的队列则需要同时维护front和rear两个指针入队在rear后插入出队在front处删除。3.4 用迭代器遍历线性结构为什么教材要单独讲迭代器教材在数组和链表之后通常会专门讲迭代器因为后面的树和图遍历都依赖迭代器模式。迭代器的核心是重载*、、!等运算符让使用者可以用统一的方式遍历不同结构。以链表迭代器为例核心成员是一个指向当前节点的指针currentNode。操作就是currentNode currentNode-next*操作返回currentNode-data!比较两个迭代器的currentNode是否相同。// 链表迭代器的前置 实现 templateclass T typename chainT::iterator chainT::iterator::operator() { currentNode currentNode-next; // 移动到下一个节点 return *this; }学习阶段建议自己手写一遍迭代器不要直接用 STL 的。手写过程中你会真正理解「为什么迭代器需要区分前置和后置」「为什么 const 迭代器和普通迭代器要分开定义」这些问题。4. 树与图从二叉树到图算法的代码落地树和图是数据结构课程的重头戏也是代码包里最值得反复调试的部分。二叉树涉及递归思维图涉及多种存储结构和遍历策略。这一章把这两块的核心实现和参数选择讲清楚。4.1 二叉树的三种遍历递归写法与迭代写法的转换二叉树的遍历是理解递归的绝佳素材。前序、中序、后序三种遍历的递归写法极其简洁// 二叉树中序遍历的递归实现 templateclass T void binaryTreeT::inOrder(binaryTreeNodeT* t) { if (t ! nullptr) { inOrder(t-leftChild); // 递归遍历左子树 visit(t); // 访问当前节点 inOrder(t-rightChild); // 递归遍历右子树 } }递归写法的关键是理解「访问时机」——前序在递归左子树之前访问中序在左子树和右子树之间访问后序在递归右子树之后访问。迭代写法需要借助栈。以中序遍历为例思路是从根节点开始一路向左把节点压栈直到没有左孩子然后弹出栈顶节点访问再转向其右孩子重复上述过程。// 二叉树中序遍历的迭代实现 templateclass T void binaryTreeT::inOrderIterative(binaryTreeNodeT* t) { stackbinaryTreeNodeT* s; binaryTreeNodeT* current t; while (current ! nullptr || !s.empty()) { while (current ! nullptr) { s.push(current); // 一路向左压栈 current current-leftChild; } current s.top(); s.pop(); // 弹出并访问 visit(current); current current-rightChild; // 转向右子树 } }迭代写法的好处是避免了递归的函数调用开销坏处是代码可读性下降。学习阶段建议两种都写对比理解。4.2 二叉搜索树插入、删除和查找的边界处理二叉搜索树BST的核心性质是左子树所有节点小于根右子树所有节点大于根。插入操作就是沿着根往下找合适位置// BST 插入操作 templateclass T void binarySearchTreeT::insert(const T theElement) { binaryTreeNodeT* p root; binaryTreeNodeT* pp nullptr; while (p ! nullptr) { // 查找插入位置 pp p; if (theElement p-element) p p-leftChild; else if (theElement p-element) p p-rightChild; else return; // 元素已存在不重复插入 } // 创建新节点并链接到父节点 binaryTreeNodeT* newNode new binaryTreeNodeT(theElement); if (pp nullptr) root newNode; // 空树新节点为根 else if (theElement pp-element) pp-leftChild newNode; else pp-rightChild newNode; }删除操作是 BST 里最复杂的分三种情况删除叶子节点直接删删除只有一个孩子的节点用孩子替代删除有两个孩子的节点用左子树最大节点或右子树最小节点替代然后删除那个替代节点。4.3 图的存储邻接矩阵与邻接链表的代码结构对比图有两种主流存储方式邻接矩阵和邻接链表。邻接矩阵用一个二维数组matrix[i][j]表示顶点 i 到 j 是否有边适合稠密图邻接链表用数组加链表每个顶点挂一个链表存相邻顶点适合稀疏图。邻接矩阵的类定义通常包含int numberOfVertices顶点数、int numberOfEdges边数、bool** matrix二维数组。插入边的操作就是matrix[i][j] true如果是带权图则存权重。邻接链表的类定义包含int numberOfVertices、int numberOfEdges、graphChainT* adjacentList链表数组。插入边时在对应顶点的链表头部插入新节点。// 邻接链表插入边 templateclass T void linkedGraphT::insertEdge(int u, int v) { if (u 0 || u numberOfVertices || v 0 || v numberOfVertices) throw illegalParameter(顶点编号越界); adjacentList[u].insert(0, v); // 在顶点 u 的链表头部插入 v numberOfEdges; }选择哪种存储方式取决于图的稠密程度。顶点数 n 和边数 e 满足 e 接近 n² 时用邻接矩阵e 远小于 n² 时用邻接链表。学习代码里两种都会提供建议都跑一遍对比内存占用和遍历速度。4.4 图的遍历BFS 和 DFS 的队列与栈实现图的遍历是很多图算法的前置步骤。BFS广度优先搜索用队列实现DFS深度优先搜索用栈实现或递归。BFS 的思路是从起点开始把它入队然后循环出队访问该顶点把它的所有未访问邻居入队。关键是要用一个visited数组标记已访问顶点避免重复访问。// 图的 BFS 遍历 templateclass T void linkedGraphT::bfs(int startVertex) { bool* visited new bool[numberOfVertices]; for (int i 0; i numberOfVertices; i) visited[i] false; queueint q; visited[startVertex] true; q.push(startVertex); while (!q.empty()) { int v q.front(); q.pop(); visit(v); // 访问当前顶点 for (auto it adjacentList[v].begin(); it ! adjacentList[v].end(); it) { int w *it; if (!visited[w]) { // 未访问的邻居入队 visited[w] true; q.push(w); } } } delete[] visited; }DFS 的迭代实现用栈替代队列思路类似但入栈顺序会影响遍历顺序。递归实现更简洁但深度过大时可能栈溢出。5. 避坑与排查学习代码包最容易翻车的五个地方学习代码包不是生产级代码里面有不少为了教学简洁而留下的坑。这一章列出我踩过的五个典型问题每个都按「现象 → 原因 → 解决」写清楚。5.1 模板类分离编译导致链接错误现象把模板类的声明放在.h实现放在.cpp编译主程序时提示「undefined reference to」某个成员函数。原因C 模板的实例化是在编译期进行的编译器需要看到完整的实现才能生成代码。如果实现放在单独的.cpp里主程序编译时看不到实现链接阶段就找不到符号。解决把模板类的实现也放到.h文件里或者在主程序里#include 实现.cpp。学习代码包里常见做法是全部写在.h里虽然看起来臃肿但避免了链接错误。5.2 浅拷贝导致指针重复释放现象程序运行到析构函数时崩溃提示「double free」或「invalid pointer」。原因类里含有指针成员但没有自定义拷贝构造函数和赋值运算符编译器生成的默认版本只做浅拷贝两个对象指向同一块内存析构时重复释放。解决为含有指针成员的类实现深拷贝。拷贝构造函数里申请新内存并拷贝数据赋值运算符里先释放旧内存再申请新内存。学习代码包里有些类故意不实现深拷贝让你自己补遇到崩溃先检查这一点。5.3 迭代器失效导致访问越界现象在遍历链表或数组时删除元素程序崩溃或输出乱码。原因删除元素后指向被删元素的迭代器失效继续使用会访问已释放的内存。解决删除元素后立即更新迭代器。对于链表可以用it list.erase(it)的形式让erase返回下一个有效迭代器。对于数组删除后所有后续元素的索引都会变化需要重新定位。5.4 递归深度过大导致栈溢出现象用递归实现 DFS 或二叉树遍历时数据量稍大就崩溃。原因递归调用会在调用栈上分配栈帧深度过大时超出栈的默认大小通常 1MB 到 8MB。解决改用迭代实现用显式的栈替代递归调用。或者增大栈大小不推荐治标不治本。学习阶段建议对深度可能很大的结构直接写迭代版本。5.5 旧版 C 语法在新编译器上报错现象编译时提示「iostream.h: No such file or directory」或「‘default’ template arguments are not allowed」等。原因教材第二版对应的代码可能基于较老的 C 标准用了iostream.h、隐式类型转换等已被弃用的写法。解决把#include iostream.h改成#include iostream加using namespace std;把旧式类型转换改成static_cast等新式转换编译时加-stdc11或更高标准。如果代码里用了NULL改成nullptr更安全。6. 进阶用法把学习代码改造成自己的算法测试框架跑通代码包只是第一步真正有价值的是把它改造成你自己的算法测试框架。我一般会做三件事加单元测试、加性能计时、加随机数据生成。这样每学一个新结构都能快速验证正确性和效率。6.1 用断言和随机数据做自动化验证学习代码包里的测试通常是硬编码的几组数据覆盖不了边界情况。我习惯加一层随机测试生成随机操作序列同时用学习代码和 STL 的对应容器执行比较结果是否一致。// 随机测试链表与 STL list 的一致性 #include list #include cstdlib #include cassert void randomTest() { chainint myList; std::listint stlList; for (int i 0; i 1000; i) { int op rand() % 3; int val rand() % 100; if (op 0) { // 插入 myList.insert(myList.size(), val); stlList.push_back(val); } else if (op 1 !stlList.empty()) { // 删除 int idx rand() % stlList.size(); auto it stlList.begin(); std::advance(it, idx); myList.erase(idx); stlList.erase(it); } // 比较大小和元素 assert(myList.size() (int)stlList.size()); } }这段代码的关键是assert一旦不一致立即中断方便定位问题。随机测试跑通后再跑固定边界测试空结构、单元素、满容量等。6.2 用 chrono 给核心操作加计时学习阶段容易忽略性能但数据结构的选择直接影响程序效率。用chrono给插入、删除、查找加计时能直观看到不同结构的差异。// 给数组插入操作计时 #include chrono #include iostream void timeArrayInsert() { arrayListint arr(1000); auto start std::chrono::high_resolution_clock::now(); for (int i 0; i 100000; i) arr.insert(0, i); // 头部插入最坏情况 auto end std::chrono::high_resolution_clock::now(); auto duration std::chrono::duration_caststd::chrono::milliseconds(end - start); std::cout 头部插入 100000 次耗时: duration.count() ms std::endl; }数组头部插入是 O(n)链表头部插入是 O(1)跑一下计时就能看到数量级差异。这种直观感受比看时间复杂度公式深刻得多。6.3 把常用结构封装成头文件库跑通所有代码后我会把常用的结构整理成一个自己的头文件库比如my_ds.h里面包含数组、链表、栈、队列、二叉搜索树等。以后写算法题或做小工具直接#include my_ds.h就能用。整理时注意几点统一命名风格比如类名用MyArrayList、MyLinkedList统一异常处理比如越界抛std::out_of_range统一迭代器接口让所有容器都支持范围 for 循环。这样用起来才顺手。6.4 用调试器单步跟踪指针操作指针操作是数据结构学习中最容易出错的地方。我习惯用 gdb 单步跟踪链表的插入和删除观察每一步指针的变化。# 用 gdb 调试链表插入 g -stdc11 -g -o chain_test chain.cpp chain_test.cpp gdb ./chain_test # 在插入函数处打断点 (gdb) break chainint::insert (gdb) run # 单步执行观察指针变量 (gdb) next (gdb) print p (gdb) print p-next通过观察p、p-next、新节点的next在每一步的值能真正理解指针是怎么连起来的。这比看十遍代码都管用。6.5 从学习代码到生产代码还差什么最后说一个我自己的教训。学习代码包里的实现为了教学清晰往往省略了很多生产环境必须考虑的东西线程安全、内存池、异常安全、移动语义。比如学习代码里的数组扩容如果new抛异常旧内存就泄漏了生产代码需要用 RAII 或智能指针保证异常安全。我一般会在学习代码跑通后挑一两个核心结构按生产标准重写一遍用std::unique_ptr管理内存用std::move优化拷贝加noexcept标记不抛异常的函数。这个过程很痛苦但能让你真正理解「为什么生产代码要这么写」。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站