37、STL容器(set与map):红黑树、有序关联容器、pair

说实话,C++ 里容器那么多,但真正让我觉得「设计得真漂亮」的,就是 set 和 map 这对兄弟。它们底层共用一颗红黑树,却对外展现出完全不同的接口。我当年第一次搞懂红黑树时,拍着桌子感叹:这玩意儿简直是数据结构与工程结合的典范。

今天咱们就聊聊这两个有序关联容器。别被「红黑树」三个字吓到,你不需要手写它,但得知道它为什么快、好在哪。

红黑树:set 和 map 的幕后功臣

先说说底层。set 和 map 在大多数标准库实现里,底层都是一颗红黑树。它是一种自平衡的二叉搜索树。

什么叫自平衡?你想想看,普通二叉搜索树如果插入有序数据,会退化成链表,查找变成 O(n)。红黑树通过颜色约束和旋转操作,保证树的高度始终在 O(log n) 级别。

红黑树的规则其实就五条:

  • 每个节点要么红要么黑
  • 根节点是黑的
  • 叶子节点(NIL)是黑的
  • 红色节点的子节点必须是黑的(不能有连续红节点)
  • 从任一节点到其每个叶子的路径,黑色节点数相同

嗯,这里要注意——你不需要背这些规则。但你要记住一个结论:红黑树的插入、删除、查找,最坏情况都是 O(log n)。我在项目中用 set 做实时数据去重,几百万条数据插进去,查询响应时间稳得像条直线。

核心结论: set 和 map 的有序性、对数级性能,都来自红黑树。你不需要手写它,但得知道它为什么靠谱。

set:简单但强大的有序集合

set 说白了就是一个不重复的有序集合。元素自动按升序排列,插入重复元素会被忽略。

我个人习惯用 set 做两件事:去重和快速查找。比如日志分析时,我需要统计所有出现过的 IP 地址——直接往 set 里扔,完事。

#include <iostream>
#include <set>
#include <string>

int main() {
    std::set<std::string> ips;
    
    ips.insert("192.168.1.1");
    ips.insert("10.0.0.1");
    ips.insert("192.168.1.1");  // 重复,被忽略
    
    std::cout << "不重复 IP 数量: " << ips.size() << "\n";
    
    // 查找
    if (ips.find("10.0.0.1") != ips.end()) {
        std::cout << "找到了 10.0.0.1\n";
    }
    
    // 遍历(自动有序)
    for (const auto& ip : ips) {
        std::cout << ip << "\n";
    }
    
    return 0;
}

输出结果:

不重复 IP 数量: 2
找到了 10.0.0.1
10.0.0.1
192.168.1.1

看到没?插入顺序是 192.168.1.1 先,但遍历时 10.0.0.1 排前面——因为字符串按字典序排了。

小技巧: 如果你需要自定义排序规则,set 的第二个模板参数可以传比较器。比如 std::set<int, std::greater<int>> 就是降序排列。

map:键值对的有序字典

map 比 set 多存了一个「值」。每个元素是一个 pair<const Key, Value>,按键排序,键唯一。

我记得有一次做配置管理系统,需要按配置名快速查找配置值——map 简直是天选之子。几万条配置,查找时间几乎不变。

#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<std::string, int> scores;
    
    scores["Alice"] = 95;
    scores["Bob"] = 87;
    scores["Charlie"] = 92;
    
    // 使用 insert 返回 pair<iterator, bool>
    auto result = scores.insert({"David", 88});
    if (result.second) {
        std::cout << "插入成功\n";
    }
    
    // 查找
    auto it = scores.find("Bob");
    if (it != scores.end()) {
        std::cout << "Bob 的分数: " << it->second << "\n";
    }
    
    // 遍历(按键排序)
    for (const auto& [name, score] : scores) {
        std::cout << name << ": " << score << "\n";
    }
    
    return 0;
}

输出:

插入成功
Bob 的分数: 87
Alice: 95
Bob: 87
Charlie: 92
David: 88

这里有个坑——operator[] 如果键不存在,会自动插入一个默认值。我曾经在排查一个 bug 时发现 map 里莫名其妙多了很多空条目,就是因为用了 [] 做查找。所以,只读查找请用 find()at()

注意: map 的 operator[] 在键不存在时会插入默认构造的值。如果你只想查找,用 find() 或 at()。at() 在键不存在时会抛 out_of_range 异常。

pair:把两个值绑在一起

pair 是 C++ 里最朴实无华的工具。它就是把两个值打包成一个对象。map 的每个元素就是一个 pair。

我经常用 pair 做函数返回值——比如同时返回状态码和结果数据。比传引用参数清爽多了。

#include <iostream>
#include <utility>  // pair
#include <string>

// 返回 pair 的函数
std::pair<bool, std::string> lookup(int id) {
    if (id == 1) {
        return {true, "找到数据了"};
    }
    return {false, "没找到"};
}

int main() {
    auto [found, message] = lookup(1);  // C++17 结构化绑定
    if (found) {
        std::cout << message << "\n";
    }
    
    // 传统方式
    std::pair<int, double> p(42, 3.14);
    std::cout << "first: " << p.first 
              << ", second: " << p.second << "\n";
    
    return 0;
}

输出:

找到数据了
first: 42, second: 3.14
提示: C++17 的结构化绑定(auto [a, b] = pair)让代码可读性提升了一个档次。如果你还在用 C++11/14,建议升级。

set 和 map 的对比

特性 set map
存储内容 单个键 键值对 (pair)
键唯一性
有序性 是(默认升序) 是(按键升序)
查找复杂度 O(log n) O(log n)
插入复杂度 O(log n) O(log n)
典型用途 去重、集合运算 字典、配置表

避坑指南

我曾经在项目里犯过一个低级错误——用 std::map::operator[] 做查找,结果插入了大量空值,导致内存暴涨。排查了半天才发现是 [] 的副作用。从那以后,我只在确定要插入或修改时才用 [],只读查找一律用 find()

还有一个坑:set 和 map 的迭代器在插入/删除操作后,只有被删除的那个迭代器会失效,其他迭代器依然有效。这是红黑树的特性——不像 vector 那样会全部失效。但别因此就掉以轻心,该注意的还是要注意。

知识体系总览

下面这张图帮你理清 set 和 map 的关系:

STL 有序关联容器知识体系 红黑树(底层实现) std::set 存储唯一键,自动排序 std::map 存储键值对,按键排序 插入:O(log n) 查找:O(log n) 元素不可修改 迭代器稳定 插入:O(log n) 查找:O(log n) operator[] 有坑 键不可修改 std::pair<Key, Value>

从这张图可以看得很清楚:set 和 map 共享同一套底层红黑树机制,区别只在于存储的内容和对外接口。pair 则是它们存储数据的基本单元——map 直接存 pair,set 存的是单个值但内部实现也依赖 pair 的机制。

好了,set 和 map 的核心内容就这些。记住一句话:需要有序、唯一、对数级查找时,优先考虑 set 或 map。它们不是万能的,但在 90% 的场景下,比你自己手写的数据结构要靠谱得多。


公众号:蓝海资料掘金营,微信deep3321