四、STL 容器与数据结构(进阶)(一)

四、STL 容器与数据结构()

一句话总览:STL 容器的选择本质上是在“连续内存、节点结构、有序性、哈希查找、插入删除效率、缓存友好性”之间做权衡;vector 是默认首选,map/set 适合有序和范围查询,unordered_map 适合平均 O(1) 的精确查找,list/deque 只在特定两端或任意位置插入删除场景下使用。

0. 知识点之间的联系

STL 容器
├─ 序列式容器
│   ├─ array / vector:连续数组,随机访问快
│   ├─ deque:分段连续,双端操作快
│   └─ list / forward_list:链表,按已知位置插入删除快
│
├─ 容器适配器
│   ├─ stack:后进先出
│   ├─ queue:先进先出
│   └─ priority_queue:堆,优先级最高者先出
│
├─ 有序关联容器
│   ├─ set / map
│   ├─ multiset / multimap
│   └─ 底层通常是红黑树
│
├─ 无序关联容器
│   ├─ unordered_set / unordered_map
│   ├─ unordered_multiset / unordered_multimap
│   └─ 底层通常是哈希表
│
└─ 横切知识点
    ├─ iterator:统一访问方式
    ├─ 算法:sort/find/copy/lower_bound
    ├─ 仿函数 / lambda:比较器、哈希函数
    └─ mutable:const 对象逻辑可修改性

选择顺序可以简单记为:

默认用 vector
需要双端进出 → deque
需要有序遍历 / 范围查询 → map/set
需要按键平均 O(1) 查找 → unordered_map
已有迭代器且频繁任意位置插入删除 → list
需要 LIFO/FIFO/优先级 → stack/queue/priority_queue

1. C/C++ 中常用容器功能汇总

核心:C 语言主要靠原生数组、malloc/realloc 动态数组、手动链表、环形缓冲区等实现容器;C++ STL 则提供自动内存管理、迭代器、泛型算法和统一接口。

C 语言没有真正意义上的标准容器库。数组大小固定,动态数组需要程序员手动管理内存;链表需要自己定义节点和指针;哈希表、平衡树也通常要手写或引入第三方库。C++ STL 将常见数据结构封装成模板类,并配合算法库实现排序、查找、复制、遍历等通用操作。

#include <array>
#include <deque>
#include <iostream>
#include <list>
#include <map>
#include <queue>
#include <set>
#include <stack>
#include <unordered_map>
#include <vector>

int main() {
    // 1. 固定大小数组
    std::array<int, 3> arr = {1, 2, 3};

    // 2. 动态数组
    std::vector<int> vec;
    vec.reserve(100);
    vec.push_back(1);
    vec.push_back(2);

    // 3. 双端队列
    std::deque<int> dq;
    dq.push_front(1);
    dq.push_back(2);

    // 4. 双向链表
    std::list<int> lst = {1, 2, 3};
    lst.push_front(0);

    // 5. 有序集合,去重且按 key 排序
    std::set<int> s = {3, 1, 2};

    // 6. 有序字典
    std::map<std::string, int> mp;
    mp["alice"] = 90;
    mp["bob"] = 85;

    // 7. 哈希字典
    std::unordered_map<std::string, int> ump;
    ump["model_a"] = 1;
    ump["model_b"] = 2;

    // 8. 栈
    std::stack<int> st;
    st.push(1);

    // 9. 队列
    std::queue<int> q;
    q.push(1);

    // 10. 优先队列,默认大根堆
    std::priority_queue<int> pq;
    pq.push(3);
    pq.push(9);
    pq.push(5);
    std::cout << pq.top(); // 9
}

常见容器对比如下:

容器逻辑结构随机访问头部插入尾部插入中间插入元素顺序
array固定数组O(1)不支持不支持不支持插入顺序
vector动态数组O(1)O(n)均摊 O(1)O(n)插入顺序
deque分段数组O(1)O(1)O(1)O(n)插入顺序
list双向链表O(n)O(1)O(1)已知位置 O(1)插入顺序
set/map红黑树不支持O(log n)O(log n)O(log n)key 有序
unordered_*哈希表不支持平均 O(1)平均 O(1)平均 O(1)无序
stack适配器不支持只访问栈顶O(1)不支持LIFO
queue适配器不支持O(1)O(1)不支持FIFO

STL 容器通常通过迭代器统一访问:

std::vector<int> v = {3, 1, 2};

for (auto it = v.begin(); it != v.end(); ++it) {
    std::cout << *it << ' ';
}

for (const auto& x : v) {
    std::cout << x << ' ';
}

使用容器时不能只背接口,还要理解底层结构。连续内存容器缓存友好,适合遍历和随机访问;节点容器插入删除稳定,但缓存局部性差;有序容器支持范围查询但单次查找为 O(log n);哈希容器平均查找快但最坏情况会退化,并且不保证顺序。实际工程中,绝大多数业务容器首选 vector,因为它最简单、最快、最容易被编译器优化。


2. 介绍一下 vector 的优缺点

核心:vector 是动态数组,底层是一块连续内存。它随机访问快、尾部追加快、遍历缓存友好,是最常用的 STL 容器;缺点是头部和中间插入删除慢,扩容时可能发生大量元素移动并使迭代器失效。

vector 内部维护三个核心概念:

起始位置        size 结束位置      capacity 容量结束位置
↓               ↓                  ↓
[1][2][3][4][5][ ][ ][ ][ ][ ]
└──── 已使用 size=5 ────┘
└──────────── capacity=10 ────────┘
  • size:当前元素个数。
  • capacity:当前已分配空间最多能放多少元素。
  • 当 size == capacity 再 push_back 时,会重新分配更大空间,把旧元素移动或拷贝过去,再释放旧空间。
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;

    std::cout << v.size() << ' ' << v.capacity() << '\n';

    v.reserve(1000); // 提前分配容量,避免多次扩容

    for (int i = 0; i < 1000; ++i) {
        v.emplace_back(i);
    }

    std::cout << v[500] << '\n';    // O(1)
    std::cout << v.at(500) << '\n'; // 带越界检查

    v.pop_back();                   // 删除尾部 O(1)
    v.insert(v.begin(), 100);       // 头部插入 O(n)
    v.erase(v.begin());             // 头部删除 O(n)
}

优点

第一,随机访问 O(1)。因为内存连续,元素地址可以通过公式计算:

第 i 个元素地址 = 起始地址 + i × sizeof(T)

第二,尾部插入效率高。容量足够时 push_back/emplace_back 是 O(1);容量不足时虽然要扩容,但多次插入的均摊成本仍是 O(1)。

第三,缓存友好。连续内存使 CPU cache line 能一次加载多个相邻元素,顺序遍历通常明显快于链表。

第四,与 C 风格数组兼容好。可以通过 data() 获取连续首地址,适合网络、图形、算法库等接口。

第五,内存自动管理,不需要手写 malloc/free,异常安全和 RAII 更可靠。

缺点

第一,中间和头部插入删除慢。在位置 i 插入需要把后面的元素整体后移:

插入前:[A][B][C][D]
在 B 前插入 X:
       [A][ ][B][C][D]
          ↑ 后面的元素全部后移
结果: [A][X][B][C][D]

第二,扩容成本高。扩容时可能拷贝或移动所有旧元素,扩容因子由实现决定,常见为 2 倍或 1.5 倍。

第三,迭代器失效问题需要重视。扩容后,旧内存被释放,所有迭代器、引用、指针通常都会失效;即使不扩容,插入位置之后的迭代器也可能失效。

std::vector<int> v = {1, 2, 3};
auto it = v.begin() + 1;
v.push_back(4);
// 若发生扩容,it 可能已经失效,不能继续使用

第四,容量可能大于实际元素数,造成一定空间浪费,可用 shrink_to_fit() 请求归还空间,但是否真的归还由实现决定。

第五,vector<bool> 是特化版本,按位存储,不完全满足普通容器语义,对单个“元素”取引用时要特别小心。

工程建议是:如果元素数量大致已知,优先 reserve;优先尾部增删;不要在循环中频繁头部插入;需要频繁中间删除时,考虑先标记后批量删除,或换用更合适的数据结构。


3. 介绍一下 list 的优缺点

核心:std::list 是双向链表,节点在堆上独立分配。它最大的优点是在已知迭代器位置时,插入和删除都是 O(1),且不会让其他元素的迭代器和引用失效;最大缺点是不支持随机访问、节点内存开销大、缓存局部性差。

list 的逻辑结构如下:

head ↔ [prev|A|next] ↔ [prev|B|next] ↔ [prev|C|next] ↔ tail

每个节点除了保存元素,还要保存前驱和后继指针。

#include <iostream>
#include <list>

int main() {
    std::list<int> lst = {1, 2, 3, 4};

    auto it = lst.begin();
    std::advance(it, 2); // 移动到第三个元素,线性时间

    lst.insert(it, 99);  // 已知 it,插入 O(1)
    lst.erase(it);       // 删除 O(1)

    lst.push_front(0);
    lst.push_back(5);

    for (int x : lst) {
        std::cout << x << ' ';
    }
}

list 还提供了一些链表特有操作:

#include <list>

int main() {
    std::list<int> a = {1, 3, 5};
    std::list<int> b = {2, 4, 6};

    a.merge(b);              // 合并两个有序链表
    a.sort();                // 链表排序
    a.unique();              // 删除连续重复元素
    a.splice(a.end(), std::list<int>{7, 8}); // 拼接另一个链表
}

优点

第一,已知位置插入删除 O(1)。不需要像 vector 那样移动大量元素,只需要修改相邻节点指针:

删除 B:
[A] ↔ [B] ↔ [C]

修改 A.next 和 C.prev:
[A] ↔──────── [C]

第二,迭代器和引用稳定性好。插入任何元素不会使已有迭代器失效;删除某个节点,也只会使指向该节点的迭代器失效,其他节点不受影响。

第三,头尾插入删除都稳定 O(1),不会像 vector 那样扩容搬移。

第四,支持 splice,可以把一个链表的一段直接摘下来挂到另一个链表,不必逐个复制元素。

缺点

第一,不支持随机访问lst[i] 不存在,访问第 i 个元素必须从头或从尾沿指针走,平均 O(n)。

第二,额外内存开销大。每个节点都要分配一次内存,并保存前驱、后继指针。存储大量小对象时,内存可能远大于 vector

第三,缓存不友好。节点地址不连续,遍历时容易发生 cache miss,因此即使 list 在理论上插入删除是 O(1),实际性能也可能因为内存分配和缓存问题输给 vector

第四,查找仍然是 O(n),因为它没有像 map 那样维护按键排序的索引。

第五,std::list 是双向链表,额外保存两个指针;如果只需要单向遍历,可使用更省空间的 std::forward_list,但它功能更受限。

很多初学者会误以为“频繁插入删除就一定该用 list”,这是不准确的。如果插入位置本身需要通过查找或遍历得到,那么总成本仍是 O(n),而且 vector 的连续内存和批量移动可能更快。list 更适合已经持有目标位置迭代器、需要稳定引用、需要链表拼接的场景,例如任务调度、LRU 链表部分、事件订阅节点管理等。


4. 介绍一下 deque 的优缺点

核心:deque 是双端队列,支持在头部和尾部高效插入删除,也支持 O(1) 随机访问。它可以理解为“分段连续数组”,兼顾了 vector 的部分随机访问能力和双端操作能力,但迭代器和内存结构更复杂。

deque 并不是一整块连续内存,而通常由一个“映射表”管理多个固定大小的数据块:

map/中控数组
┌──────┬──────┬──────┐
│ ptr0 │ ptr1 │ ptr2 │
└──┬───┴──┬───┴──┬───┘
   ↓      ↓      ↓
[ ][A][B] [C][D][E] [F][G][ ]
       头部可扩展 ↑    ↑ 尾部可扩展

因此它既能 push_front,也能 push_back

#include <deque>
#include <iostream>

int main() {
    std::deque<int> dq;

    dq.push_back(2);
    dq.push_back(3);
    dq.push_front(1);
    dq.push_front(0);

    std::cout << dq.front() << '\n'; // 0
    std::cout << dq.back() << '\n';  // 3
    std::cout << dq[2] << '\n';      // O(1) 随机访问

    dq.pop_front();
    dq.pop_back();
}

优点

第一,头尾插入删除都是 O(1)vector 在头部插入需要移动全部元素,而 deque 只需要在头部数据块不足时申请新块。

第二,支持随机访问。虽然内部不是单块连续内存,但 operator[] 仍是常数时间,因为它可以先根据下标定位数据块,再定位块内偏移。

第三,不会像 vector 那样因扩容而整体搬移所有元素。它通过增加数据块扩展空间。

第四,适合实现队列、滑动窗口、工作窃取队列、BFS 队列等双端操作频繁的结构。

#include <deque>
#include <vector>

std::vector<int> max_sliding_window(const std::vector<int>& nums, int k) {
    std::deque<int> q; // 保存下标,对应值单调递减
    std::vector<int> ans;

    for (int i = 0; i < static_cast<int>(nums.size()); ++i) {
        while (!q.empty() && nums[q.back()] <= nums[i]) {
            q.pop_back();
        }
        q.push_back(i);

        if (q.front() <= i - k) q.pop_front();
        if (i >= k - 1) ans.push_back(nums[q.front()]);
    }

    return ans;
}

缺点

第一,内部结构比 vector 复杂。随机访问需要两次定位,迭代器也要保存当前位置、数据块边界和中控表信息,因此遍历和下标访问通常略慢于 vector

第二,中间插入删除仍然是 O(n),因为要移动元素,不要把它误解成任意位置都高效。

第三,内存不是单一连续块,不能像 vector.data() 那样直接得到一个完整连续数组。

第四,没有 reserve/capacity 这类容量接口,因为它的扩容模型与 vector 不同。

第五,双端插入可能使迭代器失效;虽然元素引用通常比 vector 更稳定,但使用时仍应查阅标准规则,不要长期保存 begin/end 迭代器后继续修改容器。

简单来说:只在尾部操作优先 vector;头尾都要操作选 deque;需要 FIFO 队列时,std::queue 默认底层容器就是 deque 如果没有明确的双端需求,不要因为 deque “功能更全”就默认使用它,vector 的简单性和缓存性能通常更优。


5. 介绍一下 map & set 的优缺点

核心:map 和 set 是 C++ 有序关联容器,底层通常是红黑树,元素始终按照比较规则排序。set 只存 key,map 存 key-value;它们支持 O(log n) 的查找、插入、删除和范围查询,缺点是节点开销大、缓存局部性不如连续容器。

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

int main() {
    std::set<int> s;
    s.insert(30);
    s.insert(10);
    s.insert(20);

    for (int x : s) {
        std::cout << x << ' '; // 10 20 30,有序
    }

    std::map<std::string, int> scores;
    scores["Alice"] = 95;
    scores["Bob"] = 88;
    scores["Cindy"] = 91;

    for (const auto& [name, score] : scores) {
        std::cout << name << ": " << score << '\n';
    }

    auto it = scores.lower_bound("B");
    if (it != scores.end()) {
        std::cout << it->first << '\n'; // Bob
    }
}

四类有序关联容器区别如下:

容器是否存 valuekey 是否可重复用途
set<T>否,只存 key不可重复去重、有序集合
map<K,V>key 不可重复有序字典
multiset<T>可重复有序计数集合
multimap<K,V>可重复一个 key 对应多个值

优点

第一,元素始终有序。遍历时按 key 升序或按自定义比较器顺序输出,不需要每次排序。

第二,查找、插入、删除复杂度稳定为 O(log n)。相比哈希表平均 O(1) 但最坏可能退化,红黑树的最坏时间复杂度更可预测。

第三,支持范围查询和相邻查询lower_bound 返回第一个不小于某 key 的位置,upper_bound 返回第一个大于某 key 的位置:

std::map<int, std::string> users = {
    {10, "A"}, {20, "B"}, {30, "C"}, {40, "D"}
};

auto left = users.lower_bound(15);
auto right = users.upper_bound(30);

for (auto it = left; it != right; ++it) {
    // 输出 key 在 [15,30] 的元素:20、30
}

第四,迭代器稳定性较好。插入不会使已有迭代器失效,删除只影响被删除元素的迭代器。

第五,自定义类型只要能定义严格弱序比较规则即可作为 key。

缺点

第一,单次查找通常比哈希表慢。O(log n) 虽然优秀,但大数据量下不如 unordered_map 的平均 O(1)。

第二,节点式结构内存开销大。每个节点通常包含颜色、父指针、左指针、右指针和平衡信息,存储小对象时额外空间明显。

第三,缓存局部性差。节点分散在堆上,树遍历可能产生较多 cache miss。

第四,插入删除需要旋转、染色等平衡操作,实现成本高于普通数组。

第五,要求 key 可比较。如果比较规则定义错误,例如不满足严格弱序,可能产生未定义行为。

使用建议是:需要“有序遍历、范围查询、稳定最坏复杂度”时选 map/set;只做等值查找且不关心顺序时,优先考虑 unordered_map/unordered_set;如果 key 本身是连续小整数,甚至直接用 vector 索引可能更快。


6. 介绍一下 mutable 关键字的作用

核心:mutable 用来声明“即使对象处于 const 状态,该成员也可以被修改”。它主要用于不影响对象逻辑状态的缓存、延迟计算、互斥锁、统计信息等;lambda 表达式中的 mutable 则表示可以修改按值捕获的副本。

普通 const 成员函数中,不能修改成员变量:

class Counter {
private:
    int value = 0;

public:
    void add() const {
        // ++value; // 错误:const 成员函数不能修改普通成员
    }
};

但有些成员的修改并不改变对象“看起来的状态”。例如一个只读查询函数内部需要加锁,或者第一次查询时缓存结果,之后直接返回缓存。此时锁和缓存就适合声明为 mutable

#include <iostream>
#include <mutex>
#include <optional>

class Data {
private:
    int raw = 42;

    // const 接口中也需要加锁
    mutable std::mutex mtx;

    // 缓存不影响对象的逻辑值
    mutable std::optional<int> cache;

public:
    int get_value() const {
        std::lock_guard<std::mutex> lock(mtx);

        if (!cache.has_value()) {
            cache = raw * 2; // 逻辑上仍是只读查询
        }

        return *cache;
    }
};

使用场景一:线程安全

const 成员函数通常表示只读操作,但多线程下“读”也需要锁。mtx.lock() 会改变互斥量内部状态,因此必须把锁声明为 mutable

mutable std::mutex mtx;

使用场景二:缓存和延迟计算

#include <cmath>
#include <optional>

class Circle {
private:
    double radius;
    mutable std::optional<double> area_cache;

public:
    explicit Circle(double r) : radius(r) {}

    double area() const {
        if (!area_cache) {
            area_cache = std::acos(-1.0) * radius * radius;
        }
        return *area_cache;
    }
};

调用者多次调用 area(),从外部看结果不变,因此它仍然符合“逻辑常量性”;但内部第一次计算后会缓存结果。

使用场景三:调试统计

class Query {
private:
    mutable int query_count = 0;

public:
    int result() const {
        ++query_count; // 统计只读查询被调用次数
        return 100;
    }
};

lambda 中的 mutable

lambda 默认把 operator() 声明为 const,因此不能修改按值捕获的变量。加上 mutable 后可以修改捕获副本:

#include <iostream>

int main() {
    int x = 10;

    auto f = [x]() mutable {
        ++x;
        std::cout << x << '\n';
    };

    f(); // 11
    f(); // 12

    std::cout << x << '\n'; // 外部 x 仍然是 10
}

这里修改的是 lambda 对象内部的捕获副本,不是外部变量本身。若按引用捕获,是否加 mutable 的规则不同,因为引用本身重新绑定与修改所指对象是两回事。

注意事项

mutable 不是用来绕过 const 检查的万能手段。如果一个成员的修改会影响用户可观察结果,例如对象的真实业务值,就不应该为了编译通过而随意加 mutable。滥用它会破坏 const 正确性,让只读接口偷偷改变状态,使代码更难推理,也可能引入线程安全问题。正确判断标准是:该成员变化后,对象在逻辑上是否仍可视为未改变。 锁、缓存、统计计数通常符合;核心数据字段通常不符合。


7. map 的底层原理是什么?

核心:C++ 标准只规定 std::map 的复杂度和接口,主流标准库通常使用红黑树实现。红黑树是一种近似平衡的二叉搜索树,通过节点颜色和旋转保证树高为 O(log n),因此查找、插入、删除都能保持 O(log n)。

std::map 中的元素按 key 有序存储,每个元素是 std::pair<const Key, T>。之所以 key 带 const,是因为如果允许直接修改 key,就可能破坏树的有序结构。想修改 key,通常应先删除旧节点,再插入新节点。

二叉搜索树的基本规则是:左子树 key 小于当前节点,右子树 key 大于当前节点。中序遍历即可得到有序序列:

        30
       /  \
      10   50
       \   /
       20 40

中序遍历:10 → 20 → 30 → 40 → 50

普通二叉搜索树在最坏情况下可能退化成链表:

10
  \
   20
     \
      30
        \
         40

此时查找退化为 O(n)。红黑树通过以下规则维持近似平衡:

  1. 节点是红色或黑色。
  2. 根节点是黑色。
  3. 空叶子节点视为黑色。
  4. 红色节点的两个子节点必须是黑色,即不能有连续红色节点。
  5. 从任一节点到其所有叶子节点的路径,黑色节点数量相同。

这些规则保证最长路径不会超过最短路径的两倍左右,因此树高为 O(log n)。

#include <iostream>
#include <map>

int main() {
    std::map<int, const char*> m;

    m[3] = "C";
    m[1] = "A";
    m[2] = "B";

    for (const auto& [key, value] : m) {
        std::cout << key << ':' << value << ' ';
    }
    // 输出 1:A 2:B 3:C
}

查找过程类似二叉搜索:

find(40)

      30
     /  \
    10   50
          \
           40

40 > 30 → 去右子树
40 < 50 → 去左子树
找到 40

插入时,先按二叉搜索树规则找到位置,再插入新节点。新节点通常为红色,因为这样不会增加路径上的黑色节点数量。但插入后可能违反红黑规则,需要通过:

  • 重新染色;
  • 左旋;
  • 右旋;

恢复平衡。删除也类似,先删除节点,再通过旋转和染色修复平衡。

map 的迭代器本质上会沿红黑树进行中序遍历,所以输出始终有序:

auto it = mp.lower_bound(20);
++it; // 得到 key 顺序上的下一个元素

它的几个设计结果非常重要:

  • 查找、插入、删除:O(log n)。
  • 有序遍历:O(n)。
  • lower_bound/upper_bound:O(log n),可做范围查询。
  • 插入和删除不会导致其他元素整体移动。
  • 节点内存不连续,缓存性能不如 vector
  • key 必须能按比较器形成严格弱序。

面试中回答此题时,建议说“主流实现通常是红黑树”,而不是绝对说“标准规定必须是红黑树”。标准约束的是复杂度和行为,红黑树是最常见实现方案。与 unordered_map 的哈希表相比,map 牺牲平均查找速度,换来了有序性、稳定最坏复杂度和范围查询能力。


8. map 和 unordered_map 了解吗?

核心:map 通常基于红黑树,key 有序,查找、插入、删除为 O(log n);unordered_map 通常基于哈希表,key 无序,平均 O(1) 查找,最坏 O(n)。选择依据是是否需要顺序、范围查询、稳定最坏复杂度以及 key 是否方便定义哈希。

#include <iostream>
#include <map>
#include <unordered_map>

int main() {
    std::map<int, const char*> ordered;
    ordered[3] = "C";
    ordered[1] = "A";
    ordered[2] = "B";

    std::cout << "map 顺序:\n";
    for (const auto& [k, v] : ordered) {
        std::cout << k << ' ' << v << '\n';
    }
    // 1 2 3,按 key 有序

    std::unordered_map<int, const char> hashed;
    hashed[3] = "C";
    hashed[1] = "A";
    hashed[2] = "B";

    std::cout << "unordered_map 顺序不保证:\n";
    for (const auto& [k, v] : hashed) {
        std::cout << k << ' ' << v << '\n';
    }
}

两者对比:

对比项mapunordered_map
常见底层红黑树哈希表
元素顺序按 key 有序不保证顺序
查找O(log n)平均 O(1),最坏 O(n)
插入O(log n)平均 O(1)
删除O(log n)平均 O(1)
key 要求可比较,严格弱序可哈希,且能用 == 判断相等
范围查询支持不支持
内存树节点指针和颜色开销桶数组 + 节点开销
性能稳定性稳定受哈希冲突和 rehash 影响
适用场景有序、范围、稳定复杂度精确 key 快速查找

map 的核心优势是有序。例如需要按分数排序、按时间范围查找、找第一个大于某个 key 的元素:

std::map<int, std::string> rank = {
    {60, "pass"}, {80, "good"}, {90, "excellent"}
};

auto it = rank.lower_bound(80);
// 可以继续向后遍历 80、90

unordered_map 的核心优势是等值查找快:

std::unordered_map<std::string, int> word_count;

for (const auto& word : words) {
    ++word_count[word]; // 平均 O(1)
}

自定义类型作为 key 时,二者要求不同。map 需要比较器:

struct Point {
    int x, y;
};

struct PointCompare {
    bool operator()(const Point& a, const Point& b) const {
        if (a.x != b.x) return a.x < b.x;
        return a.y < b.y;
    }
};

std::map<Point, int, PointCompare> point_map;

unordered_map 需要哈希函数和相等判断:

#include <functional>

struct Point {
    int x, y;

    bool operator==(const Point& other) const {
        return x == other.x && y == other.y;
    }
};

struct PointHash {
    std::size_t operator()(const Point& p) const noexcept {
        std::size_t h1 = std::hash<int>{}(p.x);
        std::size_t h2 = std::hash<int>{}(p.y);
        return h1 ^ (h2 << 1);
    }
};

std::unordered_map<Point, int, PointHash> point_hash;

选择建议:如果只做 find/insert/erase 且不需要顺序,数据量较大时通常优先 unordered_map;如果需要有序输出、前缀或区间查询、性能必须有稳定上界,选 map。在嵌入式或实时系统中,哈希表 rehash 带来的不确定延迟可能不可接受,此时 map 的 O(log n) 反而更可控。还要注意,不要依赖 unordered_map 的遍历顺序,即使某次实验看起来有规律,也不是标准保证。


9. hashmap 和 map 的区别,底层数据结构算法是什么?

核心:C++ 中通常说的 hashmap 对应 std::unordered_map,底层一般是哈希表;map 底层通常是红黑树。哈希表通过哈希函数把 key 映射到桶,平均 O(1);红黑树通过有序树结构查找,稳定 O(log n)。

9.1 哈希表工作原理

哈希表的基本流程是:

key
 ↓ hash(key)
哈希值
 ↓ 对桶数取模或二次映射
bucket index
 ↓
在对应桶中查找 key

图示:

buckets
┌────────────┐
│ bucket 0   │ → (key=18,v) → (key=3,v)
├────────────┤
│ bucket 1   │
├────────────┤
│ bucket 2   │ → (key=2,v)
├────────────┤
│ bucket 3   │ → (key=7,v)
└────────────┘

不同 key 可能映射到同一个桶,这叫哈希冲突。常见冲突处理方法有:

  1. 链地址法:每个桶挂一个链表或节点序列,主流 C++ 标准库的 unordered_map 多采用这种思想。
  2. 开放寻址法:冲突后按线性探测、二次探测或双重哈希寻找下一个空位。
  3. 再哈希:使用第二个哈希函数确定步长。

C++ 标准没有强制规定具体哈希表实现,但主流实现通常使用桶加单向链表形式。

#include <iostream>
#include <unordered_map>

int main() {
    std::unordered_map<std::string, int> table;

    table["apple"] = 1;
    table["banana"] = 2;

    if (auto it = table.find("apple"); it != table.end()) {
        std::cout << it->second << '\n';
    }

    std::cout << "bucket count: " << table.bucket_count() << '\n';
    std::cout << "load factor: " << table.load_factor() << '\n';
}

负载因子为:

load_factor = 元素数量 / 桶数量

负载因子越高,冲突越多。当负载因子超过 max_load_factor 时,容器会进行 rehash,申请更多桶并重新分布元素。rehash 后迭代器通常失效,但元素引用一般仍有效。

9.2 map 工作原理

map 通常基于红黑树:

             30(B)
            /     \
         10(B)    50(B)
            \      /
           20(R) 40(R)

查找时不断比较 key:

find(40):
40 与 30 比较 → 右子树
40 与 50 比较 → 左子树
找到 40

插入、删除后通过旋转和染色维持平衡,因此高度保持 O(log n)。

9.3 核心区别

维度hashmap / unordered_mapmap
底层结构哈希表红黑树,常见实现
查找复杂度平均 O(1),最坏 O(n)O(log n)
插入复杂度平均 O(1),可能 rehashO(log n)
删除复杂度平均 O(1)O(log n)
是否有序无序按 key 有序
范围查询不适合很适合
key 要求哈希函数 + 相等判断比较函数
最坏性能哈希冲突严重时退化稳定
内存特点桶数组加节点,可能有空桶每节点平衡树指针开销
遍历顺序不稳定中序遍历有序

9.4 使用建议

如果需求是“根据用户 ID、单词、模型名快速查值”,不关心顺序,用 unordered_map

std::unordered_map<uint64_t, User> user_cache;

如果需求是“按时间排序、按分数区间统计、找最接近某个 key 的元素”,用 map

std::map<long long, Event> time_series;
auto it = time_series.lower_bound(start_time);

哈希表性能高度依赖哈希函数质量。如果 key 容易被构造出大量冲突,最坏情况可能从 O(1) 退化为 O(n),这也是算法题或安全场景中可能被哈希冲突攻击的原因。红黑树虽然平均慢一些,但每次操作的复杂度上界更稳定。

一句话总结:hashmap 用哈希换平均 O(1),但无序且最坏情况可能退化;map 用平衡树换有序和稳定 O(log n),但节点开销和常数成本更高。


总体选择建议

需求首选容器
大多数普通集合vector
已知数据量,减少扩容vector + reserve
头部尾部都频繁增删deque
先进先出queue
后进先出stack
每次取最大值/最小值priority_queue
已知迭代器位置频繁插入删除list
key 有序、范围查询map/set
只按 key 等值快速查找unordered_map/unordered_set
key 可重复且有序multimap/multiset
key 可重复且无序unordered_multimap/unordered_multiset

最重要的记忆主线是:

vector:连续数组,随机访问强,中间增删弱
deque:分段数组,双端增删强,中间仍弱
list:链表,已知位置增删强,随机访问弱
map/set:红黑树,有序、稳定 O(log n)
unordered_*:哈希表,平均 O(1),无序,最坏可能退化
mutable:const 逻辑不变时,允许修改缓存、锁等辅助成员
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值