第九章:数据结构与算法优化
说实话,很多C++程序员写了三五年代码,性能瓶颈往往不是语法不熟,而是数据结构选错了。我见过太多人用std::vector硬扛一切,结果缓存 miss 严重,程序跑得比蜗牛还慢。这一章,咱们就聊聊怎么选对数据结构,怎么让数据在内存里排兵布阵,以及两个实战利器——跳表和布隆过滤器。
9.1 选择合适的数据结构
选数据结构,说白了就是选「存取模式」。你想想看,如果你的代码里频繁做插入删除,却用了 vector,那每次插入都是 O(n) 的搬移,何必呢?
我个人习惯,先问自己三个问题:
- 数据量有多大?
- 读写比例是多少?
- 是否需要有序遍历?
举个例子。我在项目中做过一个实时日志系统,每秒写入几万条,但查询很少。一开始用了 std::map,结果 CPU 飙到 80%。后来换成 std::deque + 哈希索引,CPU 直接降到 15%。
核心原则:
- 频繁随机访问 → vector / array
- 频繁头尾插入 → deque / list
- 频繁中间插入 → list / 跳表
- 键值查找 → unordered_map(哈希)或 map(红黑树)
9.2 缓存友好型设计
嗯,这里要注意。现代 CPU 和内存之间有个巨大的速度鸿沟。CPU 每秒能执行几十亿条指令,但内存访问一次要几十纳秒。这中间的差距,全靠缓存来填。
缓存友好,说白了就是让数据尽量在连续内存里待着。你想想看,CPU 加载一个 int 时,会把相邻的几十个字节一起拉进缓存。如果你能利用好这个特性,性能翻倍不是梦。
我曾经优化过一个矩阵乘法,原始版本是逐行遍历,缓存命中率很高。但有个同事写成了逐列遍历,结果每次都要从内存重新加载,慢了 8 倍。嗯,这就是缓存不友好的典型例子。
缓存友好设计技巧:
- 优先使用
std::vector而非std::list - 结构体按访问频率排列成员(热数据放前面)
- 二维数组按行遍历,不要按列
- 避免指针链式访问(链表、树节点尽量连续分配)
// 缓存友好:按行遍历
for (int i = 0; i < N; ++i) {
for (int j = 0; j < M; ++j) {
sum += matrix[i][j];
}
}
// 缓存不友好:按列遍历
for (int j = 0; j < M; ++j) {
for (int i = 0; i < N; ++i) {
sum += matrix[i][j];
}
}
9.3 跳表(Skip List)
跳表这东西,我第一次接触是在 LevelDB 的源码里。当时觉得这玩意儿挺神奇——用概率代替平衡,实现简单,性能还不赖。
说白了,跳表就是在有序链表上加了多层索引。底层是完整的有序链表,上层是「快速通道」。查找时从顶层开始,快速跳过大量元素。
跳表 vs 红黑树:
| 特性 | 跳表 | 红黑树 |
|---|---|---|
| 实现复杂度 | 低(约200行) | 高(约500行) |
| 查找/插入/删除 | O(log n) 期望 | O(log n) 严格 |
| 范围查询 | 优秀(链表遍历) | 一般(中序遍历) |
| 并发友好度 | 高(细粒度锁) | 低(全局重平衡) |
我在项目中用跳表做过一个内存 KV 缓存。当时需要支持范围查询,红黑树写起来太麻烦,跳表三天就搞定了。而且并发读写时,只需要锁住局部节点,性能比全局锁好很多。
// 跳表节点定义(简化版)
struct SkipNode {
int key;
std::string value;
std::vector<SkipNode*> forward; // 不同层级的指针
SkipNode(int k, const std::string& v, int level)
: key(k), value(v), forward(level + 1, nullptr) {}
};
// 查找操作
bool search(int key) {
SkipNode* cur = head;
for (int i = level; i >= 0; --i) {
while (cur->forward[i] && cur->forward[i]->key < key) {
cur = cur->forward[i];
}
}
cur = cur->forward[0];
return cur && cur->key == key;
}
注意:跳表虽然实现简单,但内存开销比红黑树大。每个节点平均有 1/(1-p) 个指针(p 通常取 0.25 或 0.5)。如果内存紧张,还是用红黑树更合适。
9.4 布隆过滤器(Bloom Filter)
布隆过滤器,说白了就是一个「大概率正确」的集合。它告诉你「这个元素一定不在」或者「可能在」。用在缓存穿透、爬虫去重、垃圾邮件过滤等场景,效果拔群。
我记得有一次做推荐系统,每天要过滤几十亿次重复请求。如果用哈希表存所有已处理 ID,内存根本扛不住。后来用了布隆过滤器,每个元素只占几个 bit,内存直接降到原来的 1/100。
布隆过滤器的原理很简单:
- 一个 bit 数组(比如 1 亿个 bit)
- k 个哈希函数
- 插入时,把 k 个哈希位置都设为 1
- 查询时,检查 k 个位置是否全为 1
关键参数:
- n:预期元素数量
- p:可接受的误判率(比如 1%)
- m:bit 数组大小 ≈ -n * ln(p) / (ln2)^2
- k:哈希函数个数 ≈ (m/n) * ln2
// 布隆过滤器核心实现
class BloomFilter {
std::vector<bool> bits;
int hashCount;
size_t hash(const std::string& s, int seed) const {
// 使用不同的 seed 模拟多个哈希函数
std::hash<std::string> hasher;
return hasher(s + std::to_string(seed)) % bits.size();
}
public:
BloomFilter(size_t size, int k) : bits(size, false), hashCount(k) {}
void insert(const std::string& key) {
for (int i = 0; i < hashCount; ++i) {
bits[hash(key, i)] = true;
}
}
bool mightContain(const std::string& key) const {
for (int i = 0; i < hashCount; ++i) {
if (!bits[hash(key, i)]) return false;
}
return true; // 可能误判
}
};
避坑指南:布隆过滤器不支持删除操作!如果你需要删除,可以用 Counting Bloom Filter(每个位置用计数器代替 bit)。我曾经在项目里硬要删除,结果误判率飙升,排查了半天才发现问题。
9.5 知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以看到,数据结构选型、缓存优化、跳表和布隆过滤器,其实都围绕一个核心——「用空间换时间,用概率换效率」。
好了,这一章的内容就到这里。数据结构选型是基本功,缓存友好是进阶技巧,跳表和布隆过滤器是实战利器。你想想看,如果能把这几样东西用好,你的 C++ 程序性能至少能提升一个数量级。
我的建议:别急着优化,先用 profiler 跑一遍,找到真正的热点。然后针对性地换数据结构、改内存布局。我曾经见过有人把整个项目重写了一遍,结果性能只提升了 5%,纯粹是瞎忙活。