C++手写数据结构实战:动态数组、哈希表、AVL树与图算法
简介《Implementing Useful Algorithms in C》由Dmytro Kedyk撰写面向具备一定C基础、希望深入掌握算法设计与工程实现的开发者与计算机专业学生。全书从算法正确性证明、渐进记法与机器模型等基本原则切入系统讲解数据类型选择、内存管理、代码优化、递归控制、多算法选型与并行化等实用技术并延伸至软件工程开发周期、测试、代码审查与维护等工程实践每章末尾还附有推荐课程、学习策略与项目练习。资源包为单一PDF文件共1个文件压缩包约485.74MB内容完整、目录结构清晰便于按章节检索学习。目前已有182人学习下载。读者可借此理解算法效率的衡量方式、随机化算法的应用场景以及如何将理论落地为可运行、可优化的C代码适合作为算法进阶与工程能力提升的长期参考。1. 从一本算法书说起为什么 C 工程师绕不开手写数据结构很多人第一次翻开《Implementing Useful Algorithms in C》这类书心里想的都是“我平时用std::sort、std::unordered_map就够了为什么还要自己写红黑树、跳表、并查集”。我当年也这么想直到某次做一个日志聚合模块std::map在百万级 key 下内存直接翻了三倍插入延迟抖动到几十毫秒翻车翻得很彻底。那一刻我才明白标准库是黑匣子它替你做了取舍但取舍的代价在特定场景下会集中爆发。这本书的价值不在于教你“重新发明轮子”而在于让你看懂轮子内部的辐条怎么受力从而在需要的时候自己造一个更轻、更快、更贴合业务的轮子。它适合两类人一是写过 C 但没系统实现过基础数据结构的开发者二是被性能问题逼到必须理解底层布局的工程师。接下来的内容我会按“先立住理论、再动手复现”的节奏把这本书里最值得亲手敲一遍的几个算法拆开讲包括内存布局、参数调优和那些只有自己写过才会遇到的坑。2. 手写动态数组与哈希表从内存布局到负载因子2.1 为什么先拿动态数组开刀动态数组是所有容器的地基。标准库的std::vector已经足够好但自己实现一遍DynamicArray能让你彻底搞懂三个问题扩容时元素怎么搬、size和capacity为什么要分开、迭代器失效到底发生在哪一步。我一般会先定义一个模板类内部只保留三个成员指向堆内存的指针、当前元素个数、当前容量。扩容策略常见做法是容量不足时申请两倍空间把旧元素逐个移动过去再释放旧内存。这里有个血泪经验如果元素类型有noexcept的移动构造函数扩容时用std::move_if_noexcept能避免不必要的拷贝否则强异常安全保证下只能拷贝性能差一大截。template typename T class DynamicArray { public: DynamicArray() : data_(nullptr), size_(0), cap_(0) {} ~DynamicArray() { delete[] data_; } void push_back(const T value) { if (size_ cap_) { // 容量不足时按两倍扩容避免频繁分配 size_t newCap (cap_ 0) ? 1 : cap_ * 2; T* newData new T[newCap]; for (size_t i 0; i size_; i) { newData[i] std::move_if_noexcept(data_[i]); } delete[] data_; data_ newData; cap_ newCap; } data_[size_] value; } size_t size() const { return size_; } size_t capacity() const { return cap_; } T operator[](size_t idx) { return data_[idx]; } private: T* data_; size_t size_; size_t cap_; };上面这段代码里newCap的取值直接决定扩容频率。取 1.5 倍还是 2 倍是个经典权衡2 倍增长摊还分析下每次插入均摊 O(1)但内存峰值可能达到实际需求的近两倍1.5 倍内存更友好但扩容次数略多。我一般对内存敏感的场景用 1.5对延迟敏感的场景用 2。std::move_if_noexcept的作用是当移动构造可能抛异常且没有拷贝构造时退回拷贝保证强异常安全。如果你确定元素类型移动不抛异常直接std::move更快。2.2 哈希表的核心桶数组与冲突处理哈希表是这本书里最值得亲手实现的结构之一。标准库的std::unordered_map采用链地址法每个桶挂一个链表冲突多了查询退化。自己实现时我推荐先用开放寻址法里的线性探测跑通最小版本因为它对缓存更友好代码量也少。核心参数只有两个桶数组长度和负载因子阈值。负载因子超过 0.7 左右就该扩容否则探测链变长插入和查询都会变慢。template typename K, typename V class FlatHashMap { struct Slot { K key; V value; bool occupied false; bool deleted false; }; public: FlatHashMap(size_t initCap 16) : slots_(initCap), size_(0) {} void insert(const K key, const V value) { // 负载因子超过 0.7 时扩容重新哈希所有有效元素 if ((size_ 1) * 10 slots_.size() * 7) { rehash(slots_.size() * 2); } size_t idx hash(key) % slots_.size(); while (slots_[idx].occupied !slots_[idx].deleted) { if (slots_[idx].key key) { slots_[idx].value value; // 已存在则覆盖 return; } idx (idx 1) % slots_.size(); // 线性探测 } slots_[idx].key key; slots_[idx].value value; slots_[idx].occupied true; slots_[idx].deleted false; size_; } V* find(const K key) { size_t idx hash(key) % slots_.size(); while (slots_[idx].occupied) { if (!slots_[idx].deleted slots_[idx].key key) { return slots_[idx].value; } idx (idx 1) % slots_.size(); } return nullptr; } private: void rehash(size_t newCap) { std::vectorSlot old std::move(slots_); slots_ std::vectorSlot(newCap); size_ 0; for (auto s : old) { if (s.occupied !s.deleted) { insert(s.key, s.value); } } } size_t hash(const K key) const { return std::hashK{}(key); } std::vectorSlot slots_; size_t size_; };这段代码里deleted标记是开放寻址法的关键。直接删除会让探测链断裂导致后面的元素找不到所以只能惰性删除。rehash时把旧表整个搬过去重新插入注意size_要先清零再累加。负载因子阈值 0.7 是我实测下来在查询速度和内存占用之间比较平衡的值低于 0.5 太浪费内存高于 0.8 探测次数明显上升。哈希函数直接用std::hash如果 key 是自定义类型需要自己特化。还有一个容易忽略的点桶数组长度最好取质数或 2 的幂。取 2 的幂时可以用位运算代替取模但要求哈希函数低位分布均匀否则冲突集中取质数则对哈希函数要求低一些但取模开销略大。2.3 迭代器与内存回收的边界自己写容器迭代器失效规则必须想清楚。动态数组在扩容后所有迭代器失效哈希表在 rehash 后所有迭代器失效。我一般会在文档注释里明确写出来避免调用方踩坑。内存回收方面delete[]和delete不能混用模板类里如果用了new T[]析构必须配delete[]。另外如果元素类型本身管理资源扩容时的移动操作要保证源对象处于可析构状态否则会双重释放。这些细节标准库都替你处理了但自己写一遍就会刻在脑子里。3. 树结构实战二叉搜索树、平衡与区间查询3.1 从普通 BST 到 AVL 的旋转逻辑二叉搜索树是理解更复杂树结构的基础。普通 BST 在随机插入下期望高度是 O(log n)但有序插入会退化成链表查询变 O(n)。AVL 树通过维护每个节点左右子树高度差不超过 1 来保证平衡核心操作是四种旋转左旋、右旋、左右旋、右左旋。我建议先实现普通 BST 的插入和查找再加高度字段和旋转这样调试时容易定位问题。struct AVLNode { int key; int height; AVLNode* left; AVLNode* right; AVLNode(int k) : key(k), height(1), left(nullptr), right(nullptr) {} }; int height(AVLNode* n) { return n ? n-height : 0; } int balanceFactor(AVLNode* n) { return height(n-left) - height(n-right); } AVLNode* rotateRight(AVLNode* y) { AVLNode* x y-left; AVLNode* t2 x-right; x-right y; y-left t2; // 先更新下层节点高度再更新上层 y-height std::max(height(y-left), height(y-right)) 1; x-height std::max(height(x-left), height(x-right)) 1; return x; } AVLNode* insert(AVLNode* node, int key) { if (!node) return new AVLNode(key); if (key node-key) node-left insert(node-left, key); else if (key node-key) node-right insert(node-right, key); else return node; // 重复 key 不插入 node-height std::max(height(node-left), height(node-right)) 1; int bf balanceFactor(node); // 左左情况右旋 if (bf 1 key node-left-key) return rotateRight(node); // 右右情况左旋 if (bf -1 key node-right-key) return rotateLeft(node); // 左右情况先左旋再右旋 if (bf 1 key node-left-key) { node-left rotateLeft(node-left); return rotateRight(node); } // 右左情况先右旋再左旋 if (bf -1 key node-right-key) { node-right rotateRight(node-right); return rotateLeft(node); } return node; }旋转里最容易翻车的地方是高度更新顺序。必须先把被旋转下去的节点高度更新再更新旋转上来的节点否则高度值会错。balanceFactor用左高减右高大于 1 说明左边重需要右旋或左右旋。判断具体是哪种情况看新插入的 key 和当前节点左孩子或右孩子的 key 关系。AVL 的查询和普通 BST 一样但插入和删除后都要回溯更新高度并检查平衡。删除比插入复杂因为删除一个有两个孩子的节点需要找后继替换再回溯平衡我一般先用插入跑通再补删除。3.2 区间查询为什么需要线段树很多业务场景要频繁查询一个区间的聚合值比如数组任意区间的和、最大值、最小值。朴素做法每次遍历区间是 O(n)查询次数一多就扛不住。线段树把区间查询降到 O(log n)代价是 O(n) 的建树空间和 O(log n) 的单点更新。这本书里线段树的实现思路很清晰用数组存树根节点管整个区间左右孩子各管一半叶子节点对应单个元素。class SegmentTree { public: SegmentTree(const std::vectorint nums) { n_ nums.size(); tree_.resize(4 * n_); build(nums, 0, 0, n_ - 1); } void update(int idx, int val) { update(0, 0, n_ - 1, idx, val); } int query(int l, int r) { return query(0, 0, n_ - 1, l, r); } private: void build(const std::vectorint nums, int node, int start, int end) { if (start end) { tree_[node] nums[start]; return; } int mid start (end - start) / 2; build(nums, 2 * node 1, start, mid); build(nums, 2 * node 2, mid 1, end); tree_[node] tree_[2 * node 1] tree_[2 * node 2]; } void update(int node, int start, int end, int idx, int val) { if (start end) { tree_[node] val; return; } int mid start (end - start) / 2; if (idx mid) update(2 * node 1, start, mid, idx, val); else update(2 * node 2, mid 1, end, idx, val); tree_[node] tree_[2 * node 1] tree_[2 * node 2]; } int query(int node, int start, int end, int l, int r) { if (r start || end l) return 0; // 区间无交集 if (l start end r) return tree_[node]; // 完全覆盖 int mid start (end - start) / 2; return query(2 * node 1, start, mid, l, r) query(2 * node 2, mid 1, end, l, r); } std::vectorint tree_; int n_; };建树时数组开4 * n是经验值因为线段树不是完全二叉树最坏情况下节点数接近 4n。query里无交集返回 0 是针对求和场景如果是求最大值要返回负无穷。区间查询的递归逻辑是如果当前区间和查询区间完全没重叠返回单位元如果完全被查询区间覆盖直接返回节点值否则拆成两半分别查询再合并。单点更新沿着路径回溯更新父节点。线段树还能扩展到区间更新需要加懒标记但那是另一个话题建议先把单点更新和区间查询写熟。3.3 并查集的路径压缩与按秩合并并查集解决的是动态连通性问题比如判断两个元素是否属于同一集合、合并两个集合。朴素实现查找时可能退化成链路径压缩让查找过程中经过的节点直接指向根按秩合并让矮树挂到高树下两者结合后单次操作均摊接近 O(1)。class UnionFind { public: UnionFind(int n) : parent_(n), rank_(n, 0) { for (int i 0; i n; i) parent_[i] i; } int find(int x) { if (parent_[x] ! x) { parent_[x] find(parent_[x]); // 路径压缩 } return parent_[x]; } void unite(int x, int y) { int rx find(x), ry find(y); if (rx ry) return; // 按秩合并矮树挂到高树下 if (rank_[rx] rank_[ry]) std::swap(rx, ry); parent_[ry] rx; if (rank_[rx] rank_[ry]) rank_[rx]; } private: std::vectorint parent_; std::vectorint rank_; };find里的递归路径压缩写法最简洁但递归深度大时可能爆栈工程上可以改成迭代两趟先找到根再把路径上所有节点指向根。unite里先比较秩秩小的挂到秩大的下面只有秩相等时才增加秩。并查集不支持拆分集合所以只适合合并场景。如果业务需要撤销合并得用可持久化并查集复杂度高很多一般用不上。4. 图算法落地最短路、最小生成树与拓扑排序4.1 Dijkstra 的优先队列实现与负权边处理Dijkstra 解决非负权图单源最短路。核心是用优先队列每次取当前距离最小的节点松弛它的邻居。朴素实现 O(V^2)用二叉堆优化到 O((VE)log V)。这本书里给的实现用std::priority_queue注意它默认是大顶堆需要传greater变成小顶堆或者存负数。struct Edge { int to; int weight; }; std::vectorint dijkstra(const std::vectorstd::vectorEdge graph, int src) { int n graph.size(); const int INF std::numeric_limitsint::max(); std::vectorint dist(n, INF); dist[src] 0; // 小顶堆pair 第一个是距离第二个是节点 using P std::pairint, int; std::priority_queueP, std::vectorP, std::greaterP pq; pq.push({0, src}); while (!pq.empty()) { auto [d, u] pq.top(); pq.pop(); if (d dist[u]) continue; // 过期条目直接跳过 for (const auto e : graph[u]) { if (dist[u] e.weight dist[e.to]) { dist[e.to] dist[u] e.weight; pq.push({dist[e.to], e.to}); } } } return dist; }if (d dist[u]) continue这行是必须的因为同一个节点可能被多次入队只有距离等于当前最短距离的那次才有效。Dijkstra 不能处理负权边因为它的贪心前提是“已确定的最短距离不会再被更新”负权边会打破这个前提。如果图里有负权边但无负环用 Bellman-Ford 或 SPFA有负环则最短路无定义。工程上如果边权是浮点数注意比较时的精度问题一般用dist[u] e.weight dist[e.to] - 1e-9这种写法。4.2 Kruskal 与并查集的配合最小生成树在网络布线、聚类等场景有用。Kruskal 的思路是按边权排序从小到大依次加入如果加入的边连接了两个不同连通分量就保留否则跳过。判断连通分量正好用上一章的并查集。struct WeightedEdge { int u, v, w; bool operator(const WeightedEdge other) const { return w other.w; } }; int kruskal(int n, std::vectorWeightedEdge edges) { std::sort(edges.begin(), edges.end()); UnionFind uf(n); int total 0; int used 0; for (const auto e : edges) { if (uf.find(e.u) ! uf.find(e.v)) { uf.unite(e.u, e.v); total e.w; used; if (used n - 1) break; // 已经选够 n-1 条边 } } return total; }排序后遍历find判断是否同集合不同则合并并累加权重。used n - 1提前退出因为 n 个节点的生成树恰好 n-1 条边。如果遍历完used n-1说明图不连通不存在生成树。Kruskal 适合稀疏图Prim 适合稠密图两者复杂度在不同场景下各有优势。4.3 拓扑排序检测有向环拓扑排序用于任务调度、依赖解析。Kahn 算法基于入度每次取入度为 0 的节点输出并把它的邻居入度减一。如果最终输出的节点数小于总数说明有环。std::vectorint topoSort(const std::vectorstd::vectorint graph) { int n graph.size(); std::vectorint indegree(n, 0); for (int u 0; u n; u) { for (int v : graph[u]) indegree[v]; } std::queueint q; for (int i 0; i n; i) { if (indegree[i] 0) q.push(i); } std::vectorint order; while (!q.empty()) { int u q.front(); q.pop(); order.push_back(u); for (int v : graph[u]) { if (--indegree[v] 0) q.push(v); } } if ((int)order.size() ! n) return {}; // 有环返回空 return order; }入度统计要遍历所有边。队列初始装入度 0 的节点。每次出队一个节点加入结果并把邻居入度减一减到 0 就入队。最后比较结果长度和节点总数不等说明有环。这个算法还能顺便输出环上的节点但需要额外记录一般检测到有环就报错即可。5. 避坑与排查手写算法时最容易翻车的五个地方5.1 迭代器失效后继续使用现象程序在扩容后随机崩溃或读到脏数据。原因动态数组扩容后旧内存已释放之前保存的指针或迭代器指向已释放区域。解决任何可能触发扩容的操作后重新获取迭代器或者在接口设计上返回索引而非迭代器。我一般会在扩容函数里加断言调试阶段用_GLIBCXX_DEBUG让标准库帮忙检查。5.2 哈希函数分布不均导致性能骤降现象哈希表插入和查询从 O(1) 退化到接近 O(n)。原因自定义 key 的哈希函数只用了部分字段或者对规律性强的 key 产生大量冲突。解决用std::hash组合多个字段或者引入一个随机种子打散。测试时统计桶长度的最大值和方差如果某个桶长度超过平均值十倍基本就是哈希函数有问题。5.3 递归深度过大导致栈溢出现象树或图规模到十万级时程序直接段错误。原因递归实现没有控制深度默认栈空间只有几 MB。解决改成迭代版本或者手动用std::stack模拟递归。线段树和并查集的find是重灾区并查集find建议改迭代线段树查询如果区间大也建议迭代。5.4 浮点权重比较精度丢失现象Dijkstra 在浮点权图上结果偶尔不对。原因dist[u] w dist[v]在浮点下可能因为舍入误差判断错误。解决引入 epsilon写成dist[u] w dist[v] - 1e-9。epsilon 取值根据数据量级定一般 1e-9 到 1e-6。如果精度要求极高考虑用整数缩放成定点数。5.5 内存泄漏与双重释放现象长时间运行内存持续增长或者析构时崩溃。原因new和delete不配对或者移动后源对象未置空。解决用 RAII 包装资源析构函数里只释放一次移动构造函数里把源指针置nullptr。我习惯在开发阶段用地址消毒剂跑一遍测试能抓出大部分内存问题。6. 进阶技巧用基准测试驱动算法选型写到这儿算法本身基本能跑通了但“能跑”和“跑得好”之间还差一层基准测试。我一般会用一个简单的计时框架对同一组数据分别跑自己实现的容器和标准库容器对比插入、查询、删除的耗时和内存占用。下面这个片段用std::chrono测插入耗时注意要关掉编译器优化对空循环的消除所以累加结果要实际使用。#include chrono #include vector #include unordered_map #include cstdio template typename Func double benchmark(Func f, int iterations) { auto start std::chrono::high_resolution_clock::now(); for (int i 0; i iterations; i) f(i); auto end std::chrono::high_resolution_clock::now(); std::chrono::durationdouble, std::milli diff end - start; return diff.count() / iterations; } int main() { const int N 1000000; // 测试 std::unordered_map 插入 double t1 benchmark([](int i) { static std::unordered_mapint, int m; m[i % N] i; }, N); // 测试自己实现的 FlatHashMap 插入 double t2 benchmark([](int i) { static FlatHashMapint, int m; m.insert(i % N, i); }, N); std::printf(std::unordered_map: %.6f ms/op\n, t1); std::printf(FlatHashMap: %.6f ms/op\n, t2); return 0; }benchmark函数接收一个可调用对象和迭代次数返回每次操作的平均毫秒数。static变量保证容器在多次调用间复用避免反复构造影响测量。实际跑的时候要注意先跑一轮预热让缓存和分支预测稳定每项测试至少跑三次取中位数数据规模要覆盖小、中、大三种因为不同规模下缓存命中率不同结论可能反转。我自己的经验是开放寻址的哈希表在数据量小于缓存容量时明显快于链地址法但数据量超过缓存后优势缩小甚至反转因为探测链变长导致缓存失效。所以选型不能只看理论复杂度要结合你的实际数据规模和访问模式。另一个习惯是每次改完算法实现先跑一遍基准确认没有性能回退再提交。这个习惯帮我省下了无数次“上线后才发现慢了三倍”的后悔药。希望帮到你。本文还有配套的精品资源点击获取