ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据结构与算法分析C++第四版参考答案:完整实现与调试指南

数据结构与算法分析C++第四版参考答案:完整实现与调试指南 简介《数据结构与算法分析C语言描述第四版》的配套答案与源码包面向正在研读教材、希望在C环境中验证数据结构与算法原理的学习者。压缩包共100个文件核心为63个cpp与22个hcpp可直接编译运行h封装类与接口覆盖动态数组、链表、队列、堆、哈希表、二叉搜索树以及排序、查找、图算法等实现另有12个docx用于拆解习题思路并附html、txt说明整体仅4.65MB便于即下即用。已有3703人浏览学习。读者可获得教材练习参考答案与可运行源码边对照边调试代码中带有测试用例与复杂度分析可加深对快速排序、归并排序、Dijkstra、Prim、红黑树等经典主题的理解也能熟悉C模板、STL、智能指针等现代C用法便于对照书本逐章演练。对需要系统刷题、准备面试或夯实算法基础的程序员是实用的自学和复盘材料。1. 数据结构与算法分析C语言描述第四版参考答案让每一个数据结构当场跑起来学数据结构与算法分析C描述最让人崩溃的不是概念看不懂而是代码抄下来编译不过。这本书里的算法大多以容器类和递归为主缺头文件、缺类型声明、缺一份能跑的main书上的片段很难直接变成程序。这份参考答案的价值恰恰是把教材里分散的接口描述串成完整可运行的代码覆盖表、栈、队列、二叉搜索树、哈希、堆、排序等核心内容适合正在跟第四版自学、准备期末或复试、以及想把“看懂”变成“跑通”的人。下面按我实际用的顺序拆给你看。2. 把参考答案落到工程里从目录结构到第一个能跑的编译单元2.1 先读目录这份参考答案的章节组织形式与入口识别打开资源包的第一件事不是看代码而是看目录。这份参考答案通常按教材章节组织从表、栈、队列到树、哈希、优先队列、排序每一章一个独立目录目录里由 .h 和 .cpp 组成。教材里每个结构的接口设计是统一的所以整套代码的风格也统一类模板、带下划线的成员函数、还有配套的测试主函数。你在文件夹里见到十几二十个文件很正常别慌。接下来找入口。最省事的办法是搜索 int main命中的那个 .cpp 就是可执行入口。如果没有单独的 main那就在当前章目录里新建一个把要测的 class include 进来先跑通最简单的插入、查找和删除。把这当作第一个里程碑这个范式对后边所有的数据结构都成立。不要一上来就看最复杂的 AVL 或优先队列那种文件往往一个就超过几百行直接看会很快失去耐心。关于文件组织我一般会按“一章一个工程”的方式来对待而不是把它们当同一个工程的多个模块。比如第四章的树相关代码单独建一个文件夹第五章哈希单独建一个。原因在于答案代码内部为了保持教学可读性经常会出现同名的辅助函数和类集中放到一个工程里很容易产生重复定义反而拖慢你确认“跑通了”这件事的速度。2.2 模板类接口形态为什么每一章的数据结构都长这样翻开任意一个数据结构的头文件你几乎都会看到三个固定成员析构函数、拷贝构造函数、拷贝赋值运算符。教材第四版采用的是完整的类模板写法和三件套设计这不仅是工程习惯更是数据结构必须满足的语义要求。一个二叉搜索树节点靠指针串联如果复制时只做浅拷贝两个对象会共享同一串节点析构的时候就会 double free。一个典型的二叉搜索树类声明会是这样template typename Comparable class BinarySearchTree { public: BinarySearchTree() : root{nullptr} {} BinarySearchTree(const BinarySearchTree rhs) : root{nullptr} { root clone(rhs.root); } ~BinarySearchTree() { makeEmpty(root); } BinarySearchTree operator(const BinarySearchTree rhs) { if (this ! rhs) { makeEmpty(root); root clone(rhs.root); } return *this; } private: struct BinaryNode { Comparable element; BinaryNode* left; BinaryNode* right; BinaryNode(const Comparable e, BinaryNode* l, BinaryNode* r) : element{e}, left{l}, right{r} {} }; BinaryNode* root; };逻辑说明root 是该树唯一的入口指针clone 递归复制整棵树makeEmpty 递归释放所有节点。拷贝赋值两步走先清空自己再整体克隆一份这是深拷贝的标准姿势。参数说明Comparable 是模板参数对应树上存放的元素类型它必须支持 运算符因为树的有序性全靠比较实现。如果你把 Comparable 换成自定义结构体记得给它重载 operator否则编译到 insert 那一步会直接报错。这里还有个容易被忽略的点拷贝赋值里为什么要先判断 this ! rhs因为如果对象自己给自己赋值先 makeEmpty 会把自己的元素清空再把已经空掉的自己克隆一遍等于把树删没了。参考答案里十有八九带了这句守卫但你从书上看不到这层含义这正是“参考答案比书上的片段多出来的价值”。2.3 第一个编译动作g 参数、CMake 配置与最小验证流程先找一个最简单的结构下手我的习惯是从二叉树或者栈开始因为这些类型的代码量最小、依赖最少。g --stdc11 -Wall -g -O0 -o test_tree BinarySearchTree.cpp main.cpp ./test_tree参数说明--stdc11 指定语言版本。因为答案里大量使用列表初始化、auto、移动语义这些特性需要 C11 以上才能编译与其纠结老标准不如统一开到 C11。-Wall 打开全部警告模板类代码最容易出现“有符号无符号比较”之类的警告开了它等于给你提前排雷。-g 生成调试信息后续用 gdb 看段错误位置时必需。-O0 关闭优化学习阶段保证调试信息对应的行号是准确的。如果一次要编译多个文件我建议直接写一个 CMakeLists.txt哪怕只有几行cmake_minimum_required(VERSION 3.10) project(data_structure_practice) add_executable(test_bst BinarySearchTree.cpp main.cpp)CMake 的好处是文件一多的时候不用每次手敲 g 那串参数而且它对模板类的编译方式没有特殊要求只要能通过命令行编译它就能通过 CMake。到这里你已经有第一个能编译、能运行、能在里面插数据的数据结构了。后面所有章节都可以按这套流程复制唯一的区别只是 include 的头文件变了。3. 手写三大核心结构二叉堆、AVL树、分离链接哈希表3.1 二叉堆下滤操作与 vector 容量陷阱二叉堆是优先队列的经典实现它的核心操作只有两个insert 时的上滤和 deleteMin 时的下滤。你在参考答案里看到的大多数方法都是围绕这两个动作展开的。第四版里的 BinaryHeap 类用 vector 存数据下标从 1 开始0 号位置留作哨兵或者干脆弃用目的是让节点 i 的左右孩子刚好是 2i 和 2i1代码写起来不用做下标换算。下滤操作是这类代码的灵魂template typename Comparable void BinaryHeapComparable::percolateDown(int hole) { int child; Comparable tmp std::move(array[hole]); for (; hole * 2 currentSize; hole child) { child hole * 2; if (child ! currentSize array[child 1] array[child]) child; if (array[child] tmp) array[hole] std::move(array[child]); else break; } array[hole] std::move(tmp); }逻辑说明先取出当前洞里的元素存到 tmp然后看它的两个孩子。child 先指向左孩子如果右孩子存在且比左孩子小就切到右孩子。只要较小的孩子比 tmp 还要小就把孩子上移再把洞往下挪。最后 tmp 落停在正确位置。参数说明hole 是数组下标表示当前的空洞位置currentSize 是堆里实际元素个数它一定小于等于 vector.size()因为 vector 可能有预留容量。所以循环条件用的是 currentSize 而不是 size()写成 size() 会越界访问还没初始化的位置。踩坑提示deleteMin 的正确流程是把最后一个元素搬到根再对根做一次下滤。我看到有相当多的改法是把 vector 里的其他元素整体左移或者直接调 erase这两种做法都会破坏堆序性质也把 vector 的迭代器搞乱。正确解法永远只是“搬最后一个 下滤”不要自创。3.2 AVL 树单旋双旋的返回值与高度更新顺序AVL 是二叉搜索树的进阶版它通过保证每个节点的左右子树高度差不超过 1 来避免极端退化。参考答案的代码会分四个场景左左、右右、左右、右左。前两个用单旋后两个用双旋。重点不在于记住旋转方向而在于理解旋转操作在代码里是一个返回新子树的函数。最经典的单旋实现是这样的template typename Comparable typename AvlTreeComparable::AvlNode* AvlTreeComparable::rotateWithLeftChild(AvlNode* k2) { AvlNode* k1 k2-left; k2-left k1-right; k1-right k2; k2-height max(height(k2-left), height(k2-right)) 1; k1-height max(height(k1-left), height(k2)) 1; return k1; }逻辑说明k2 是失衡节点k1 是它的左孩子。把 k1 的右子树挂到 k2 的左子树上再让 k1 的右孩子变成 k2这样就完成了一次右旋。旋转完 k2 变成了新树根 k1 的左子树所以要先更新 k2 的高度再更新 k1 的高度顺序不能反反了会导致上层判断失衡时拿到过期数据。参数说明函数返回值是新子树的根调用处必须接住这个返回值比如 root rotateWithLeftChild(root)漏掉这条赋值旋转就白做了树会原地不变。这是教材答案里最容易抄丢的一行。双旋本质就是两次单旋先对左孩子做一次反向旋转再对根做一次正向旋转。参考答案通常会封装成 doubleRotate 系列。自己验证的最好办法是打印出每个节点的高度左右子树差超过 1 就是旋转时机不对别急着调下一行代码。3.3 分离链接哈希表内存释放与负载因子哈希表在第四版的实现是分离链接法每个桶放一个链表。核心数据结构是 vectorlist 装载因子超过某阈值时扩容并 rehash。参考答案的哈希函数一般会在标准 hash 之后再取模分布。看这份资源时重点盯两点一是析构是不是真的把所有链表节点都删干净二是 rehash 时有没有正确处理旧元素。一个简化版本的插入逻辑可以这样理解template typename HashedObj void HashTableHashedObj::insert(const HashedObj x) { if (currentSize theLists.size()) rehash(); int whichList myhash(x); if (!contains(x)) theLists[whichList].push_back(x); }逻辑说明currentSize 先自增再判断是否超过桶的总数超过就 rehash。insert 里的去重判断不能省因为分离链接允许冲突但语义上不允许重复 key。参数说明theLists[i] 是一个链表桶号由 myhash 内部用 hash 函数加取模得到。如果你切到字符串键默认的 std::hash 在多数实现下表现尚可如果是自定义对象必须自己提供 hash 特化否则编译期直接报缺少对应重载。内存部分参考答案因为用了 std::list 做桶析构由容器自己负责真正的危险点在拷贝构造时重复释放同一个链表节点。所以哈希表的拷贝控制重点看它的 operator 是不是先清空再逐桶复制只检查插入逻辑是不够的。4. 排序算法与字符串匹配归并排序、快速排序与 KMP 的答案级解读4.1 归并排序临时数组该在哪个层级分配归并排序在教材答案里一般写成一个递归驱动函数加一个合并函数。合并函数需要一块临时数组暂存有序结果。参考答案里可能有两种风格一种是在递归函数里每次 new 一个数组用完后释放另一种是只分配一次传给每一层递归。前者代码好看但性能差后者才是工程里常用的版本。合并的核心逻辑是双指针归并写出来是这样void merge(int a[], int tmpArray[], int leftPos, int rightPos, int rightEnd) { int leftEnd rightPos - 1; int tmpPos leftPos; int numElements rightEnd - leftPos 1; while (leftPos leftEnd rightPos rightEnd) { if (a[leftPos] a[rightPos]) tmpArray[tmpPos] a[leftPos]; else tmpArray[tmpPos] a[rightPos]; } while (leftPos leftEnd) tmpArray[tmpPos] a[leftPos]; while (rightPos rightEnd) tmpArray[tmpPos] a[rightPos]; for (int i 0; i numElements; i, --rightEnd) a[rightEnd] tmpArray[rightEnd]; }逻辑说明三个 while 分别处理“两边都没走完”和“一边走完另一边还有剩”的情况。最后回写时通过 rightEnd 从后往前填这样避免再开一个起始下标变量。参数说明leftPos、rightPos、rightEnd 都是闭区间端点leftEnd 必须由 rightPos - 1 推算算错一位数组后半段会永远排不进去。记忆点归并的稳定性来自合并时遇到相等元素先取前段也就是代码里用的是 而不是 。你要是把 改成 相等元素的相对顺序就会被破坏虽然整数排序结果看不出差异但排序对象带附加字段时就会出问题。4.2 快速排序三数取中与插入排序阈值对于第四版这种偏向教学实现的快排答案一般会给三数取中版和 STL 里的 sort 逻辑接近。三数取中就是取左端、中间、右端的三个元素取其中位数作为 pivot并把中位数交换到末尾附近。这么做的好处是能极大减少最坏情况的出现概率尤其是针对已经有序的输入。const Comparable median3(vectorComparable a, int left, int right) { int center (left right) / 2; if (a[center] a[left]) swap(a[left], a[center]); if (a[right] a[left]) swap(a[left], a[right]); if (a[right] a[center]) swap(a[center], a[right]); swap(a[center], a[right - 1]); return a[right - 1]; }逻辑说明三次比较完成了三分排序结果是最小在 left、最大在 right、中位数在 center。把中位数 swap 到 right - 1 的位置是为了让主分割算法只用比较 a[left] 与 pivot。因为 pivot 已经放到倒数第二个位置right 位置天然比 pivot 大i 和 j 的越界检查可以少写两个条件。参数说明a 是待排序数组left 和 right 是闭区间起点终点返回值是 pivot 的引用。这里一个常见坑是边界值当数组长度小于 3 时三数取中会越界所以驱动函数里要有 if (right - left 1 阈值) 的提前判断小于该阈值时改用插入排序。第四版答案里的阈值一般取 10 左右这个数字不是拍脑袋定的而是实测里快排递归到底的开销开始大于插入排序的临界点。4.3 KMP 的 next 数组教材之外最常见的补充练习第四版正文并没有单独为 KMP 开章节但网上的配套代码里 KMP 往往是高频出现的补充练习。如果你在答案包里没找到它自己加一个对照测试就行。KMP 最难理解的是 next 数组不是简单的“前缀等于后缀的长度”而是失败时模式串指针该回退到的位置。这里给出最常用的 -1 起始版本void computeNext(const std::string pat, std::vectorint next) { int i 0, j -1; next.resize(pat.size()); next[0] -1; while (i (int)pat.size() - 1) { if (j -1 || pat[i] pat[j]) { i; j; next[i] j; } else { j next[j]; } } }逻辑说明j 表示已匹配的前缀长度。当字符相等时i 和 j 同时前进next[i] 记录为 j。当字符不相等时j 回退到 next[j]继续尝试。这本质是模式串自己和自己做一次匹配。参数说明next 数组的长度等于模式串长度next[0] 固定为 -1表示第一个字符失配时模式串头要从头开始且主串 i 要前进一位。如果你在网上看到 next[0] 0 的版本它们的偏移语义完全不同混用会陷入死循环。验证这个函数正确性的最快方法是把模式串 ababaca 放进去算一遍手动推出 next 为 -1, 0, 0, 1, 2, 3, 0再用一个简单的主串做索引对照打印。实际对比中参考答案和网上版本的差异几乎都集中在 next 起始值和回退条件上这正是 KMP 最容易写错的两行。5. 避坑参考答案里的五个典型编译与运行事故这份参考答案给的是“正确代码”但正确代码碰到不同平台、不同编译器、不同 C 标准时也会翻车。下面列出的五个问题是同学和网友反馈里重复率最高的五类。每一条都按现象、原因、解决的顺序来写你可以直接对号入座。5.1 编译期模板类跨文件编译与头文件循环包含事故一模板类跨文件编译导致的链接失败。现象是 g 编译单个 .cpp 全部通过最后链接时报 undefined reference指向 insert、remove 之类的成员函数。原因在于模板只有在实例化时才会生成代码主函数所在的编译单元看不到模板实现自然没法实例化。判断方法很简单报错符号都带模板参数比如BinarySearchTreeint::insert(int)就基本锁定了。解决方法是把实现直接写进 .h采用“头文件即实现”的方式或者把实现文件改名 .inl 并在 .h 末尾 include 进来。从那以后我拿到答案第一件事就是看 .cpp 里有没有非成员函数的实现代码提前规避。事故二头文件循环包含导致大量重定义报错。现象是编译时出现 error C2011 类似 class type redefinition或者大量“未定义标识符”的连环报错。原因在于 AVL、BST、堆这些结构经常互相依赖而答案代码为了保留原始结构并没有做前置声明。解决方法是给每个头文件顶部加 #pragma once这是最快最稳的修法比传统的手写 include guard 省事得多。加了之后如果还报错就按依赖顺序手动调整 include 的位置先 include 最底层的辅助头文件。5.2 运行期删除、越界与算法版本混用事故三删除节点后悬空指针造成段错误。现象是程序偶尔崩溃而且崩溃位置每次不一样gdb 里通常指向访问某个非法地址。原因常见于 remove 操作里只 delete 了节点却没有把父节点对应的指针置为 nullptr 或者接上新子树。比如二叉搜索树删除两个孩子都存在的节点时教材答案采用“找右子树最小元素替换然后删除最小元素”的策略如果替换后忘记把原指针复位整棵树就断了。解决把删除路径上的所有指针地址打印出来对照树的结构做一次纸面推演重点确认 delete 之前那个节点已经被新子树接管。事故四vector 下标越界。现象是没有明显的编译错误运行时出现读取位置冲突或者在 Debug 模式下直接弹出 vector subscript out of range。原因是教材中堆和哈希表都有下标偏移约定堆从 1 开始哈希表可能保留 0 号位而你在循环里用了习惯的 0 起始下标于是末尾多算了一个不存在的元素。解决办法统一用 currentSize 或者 size() 控制循环次数不要自己用元素个数去反推边界。日常调试还有个习惯值得培养临时把 operator[] 换成 at()虽然慢一点但能在 Debug 阶段立刻告诉你越界点在哪定位完再换回来。事故五KMP 的 next 数组版本混用导致死循环。现象是匹配模式串时程序卡死甚至把 while 循环跑成死循环CPU 占用直接拉满。原因是把 -1 起始版本和 0 起始版本的 next 逻辑混在同一个工程里paste 代码时没注意到注释里的约定。解决在代码顶部明确标注“此 next 数组 -1 起始与 0 起始版本不兼容”每次粘贴后检查一行next[0] 是 -1 还是 0。如果想彻底避免这类问题就把 KMP 封装成一个独立的 .h 文件内部自带 computeNext不要跟其他匹配算法共用一个文件。6. 让答案变成自己的实验报告三种验证手段参考答案的价值在于给你一条正确的路径而不是让你照着敲完就完事。我自己的习惯是每个结构学完把它的测试主函数改造成一个可以反复跑的最小验证程序。第一种手段是断言式验证。不要依赖 print 输出肉眼看对错用 assert 把不变式写进去。比如二叉堆 deleteMin 之后可以用一个 isHeap 函数检查整体堆序AVL 删除之后遍历检查每个节点的高度差。把这样的检查函数加进答案代码里比任何手工测试都可靠最后这部分代码可以直接写进实验报告当作“正确性验证”一节。第二种手段是可视化中间状态。树结构比数组难观察我一般写一个打印函数把节点元素和高度一并输出用缩进表示层级。堆则可以直接打印底层 vector 的下标和值然后手动验证父节点是否小于孩子。这个方法在调试 AVL 旋转时特别管用每一次旋转前后都打一次你会发现高度更新顺序的影响立刻现形。第三种手段是拿 OJ 上的经典题做端到端对照。二叉树的题做一遍中序、层序遍历堆的题用 priority_queue 验证排序的题做一个大数据量随机数组验证有序性。运行结果一致说明你对答案的理解是完整的如果不一致先怀疑拷贝控制再怀疑边界值。这里有个实用顺序先测简单输入再测空结构和单元素结构最后测大量随机数据三种都没问题这段代码才真正属于你了。我现在的习惯是拿到任何一份教材参考答案第一件事都是删掉它自带的 main自己写测试壳把每个结构当成独立模块来验证。这套流程帮我避开了无数个“抄对了却不敢维护”的尴尬场面。希望帮到你。本文还有配套的精品资源点击获取
返回列表