news 2026/10/14 3:04:28

C++手写数据结构实战:动态数组、哈希表、AVL树与图算法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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::vector<Slot> old = std::move(slots_); slots_ = std::vector<Slot>(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::hash<K>{}(key); } std::vector<Slot> 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::vector<int>& 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::vector<int>& 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::vector<int> 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::vector<int> parent_; std::vector<int> rank_; };

find里的递归路径压缩写法最简洁,但递归深度大时可能爆栈,工程上可以改成迭代两趟:先找到根,再把路径上所有节点指向根。unite里先比较秩,秩小的挂到秩大的下面,只有秩相等时才增加秩。并查集不支持拆分集合,所以只适合合并场景。如果业务需要撤销合并,得用可持久化并查集,复杂度高很多,一般用不上。

4. 图算法落地:最短路、最小生成树与拓扑排序

4.1 Dijkstra 的优先队列实现与负权边处理

Dijkstra 解决非负权图单源最短路。核心是用优先队列每次取当前距离最小的节点,松弛它的邻居。朴素实现 O(V^2),用二叉堆优化到 O((V+E)log V)。这本书里给的实现用std::priority_queue,注意它默认是大顶堆,需要传greater变成小顶堆,或者存负数。

struct Edge { int to; int weight; }; std::vector<int> dijkstra(const std::vector<std::vector<Edge>>& graph, int src) { int n = graph.size(); const int INF = std::numeric_limits<int>::max(); std::vector<int> dist(n, INF); dist[src] = 0; // 小顶堆,pair 第一个是距离,第二个是节点 using P = std::pair<int, int>; std::priority_queue<P, std::vector<P>, std::greater<P>> 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::vector<WeightedEdge>& 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::vector<int> topoSort(const std::vector<std::vector<int>>& graph) { int n = graph.size(); std::vector<int> indegree(n, 0); for (int u = 0; u < n; ++u) { for (int v : graph[u]) ++indegree[v]; } std::queue<int> q; for (int i = 0; i < n; ++i) { if (indegree[i] == 0) q.push(i); } std::vector<int> 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::duration<double, 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_map<int, int> m; m[i % N] = i; }, N); // 测试自己实现的 FlatHashMap 插入 double t2 = benchmark([&](int i) { static FlatHashMap<int, 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变量保证容器在多次调用间复用,避免反复构造影响测量。实际跑的时候要注意:先跑一轮预热,让缓存和分支预测稳定;每项测试至少跑三次取中位数;数据规模要覆盖小、中、大三种,因为不同规模下缓存命中率不同,结论可能反转。我自己的经验是,开放寻址的哈希表在数据量小于缓存容量时明显快于链地址法,但数据量超过缓存后优势缩小甚至反转,因为探测链变长导致缓存失效。所以选型不能只看理论复杂度,要结合你的实际数据规模和访问模式。另一个习惯是:每次改完算法实现,先跑一遍基准,确认没有性能回退再提交。这个习惯帮我省下了无数次“上线后才发现慢了三倍”的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:03:02

Google地球网页版:WebGL渲染、瓦片加载与API嵌入实战

简介&#xff1a;这份资源面向对地理信息系统与Web前端开发感兴趣的初学者及开发者&#xff0c;提供一份可直接在浏览器中运行的Google地球网页版实现&#xff0c;帮助理解HTML与JavaScript如何驱动交互式3D地球模型。压缩包内共1个文件&#xff0c;为单个html页面&#xff0c;…

作者头像 李华
网站建设 2026/10/14 3:02:49

Apache OpenDAL DBFS 服务接入与配置实战指南

数据存储后端 【免费下载链接】opendal Apache OpenDAL: One Layer, All Storage. 项目地址&#xff1a; https://gitcode.com/gh_mirrors/op/opendal 点击查看 免费下载 本篇指南围绕 Apache OpenDAL 的 opendal-service-dbfs 服务实现展开&#xff0c;讲解如何通过统一的数据…

作者头像 李华
网站建设 2026/10/14 3:01:01

Python代码格式化工具Black:终结团队风格之争的实战指南

代码格式化这件事&#xff0c;我一度觉得是程序员的“信仰战争”。团队里最浪费时间的几件事&#xff0c;开需求评审算一个&#xff0c;讨论缩进、引号、换行风格也能算一个——有人坚持单引号&#xff0c;有人力推双引号&#xff0c;有人写函数参数像掏口袋一样一个一个往外掏…

作者头像 李华
网站建设 2026/10/14 3:00:56

ET2017免狗版部署全指南:绕过加密狗校验,解决授权失效问题

简介&#xff1a;面向受加密狗与打印驱动问题困扰的ET2017软件用户&#xff0c;这份免狗万能版压缩包将新一代引擎、授权模块和打印驱动整合在一起&#xff0c;省去单独寻找补丁和驱动的流程&#xff0c;适合机械、电气、建筑等工程制图场景。压缩包为rar格式&#xff0c;整体体…

作者头像 李华