Skip to content

STL

vector 底层是什么?

一句话理解:std::vector 底层是“连续动态数组”。它像一个可以自动扩容的数组:元素连续存放,支持快速下标访问;容量不够时,会重新申请更大的连续内存,把旧元素搬过去。

vector-underlying

vector 对象本身通常很小

比如:

c
std::vector<int> v;

v 这个对象本身通常只保存一些管理信息,常见实现类似:

begin:指向第一个元素
end:指向最后一个元素的下一个位置
end_capacity:指向容量末尾

也可以理解成:

data 指针
size 当前元素数量
capacity 当前可容纳数量

注意:具体内部布局是实现细节,不同编译器不一定完全一样。 但标准保证:vector 的元素是连续存储的。

元素通常在堆上连续存放

c
#include <vector>
using namespace std;

int main()
{
    vector<int> nums;

    nums.push_back(10);
    nums.push_back(20);
    nums.push_back(30);

    return 0;
}

逻辑上像这样:

c
nums 对象本身:
data  ────────┐
size = 3
capacity = 6

堆上连续数组:
[10][20][30][空][空][空]

所以:

c
nums[0]
nums[1]
nums[2]

访问很快。

为什么 vector 随机访问快

因为元素连续。

c
nums[i]

底层大概就是:

c
data + i * sizeof(T)

所以随机访问复杂度是:

c
O(1)

这也是 vector 比链表更适合大量遍历和随机访问的原因。

size 和 capacity 区别

c
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<int> v;

    v.reserve(5);

    v.push_back(10);
    v.push_back(20);

    cout << "size = " << v.size() << endl;
    cout << "capacity = " << v.capacity() << endl;

    return 0;
}

可能输出:

c
size = 2
capacity = 5

意思是:

size:当前真的有几个元素。
capacity:当前这块内存最多能放几个元素。

reserve(5) 只是提前申请容量,不会创建 5 个元素。

reserve 和 resize 区别

c
vector<int> v;

v.reserve(10);

意思是:

容量至少变成 10。
size 还是 0。
没有真正创建 10 个 int 元素。
v.resize(10);

意思是:

size 变成 10。
真的创建 10 个元素。

面试里要说清楚:

reserve 改 capacity。
resize 改 size。

vector 扩容过程

当:

c
size == capacity

此时再 push_back,容量不够,就要扩容。

扩容通常做这些事:

1. 申请一块更大的连续内存
2. 把旧元素移动或拷贝到新内存
3. 析构旧内存里的元素
4. 释放旧内存
5. 更新 data、size、capacity

示例:

c
vector<int> v;

v.push_back(1);
v.push_back(2);
v.push_back(3);

如果容量不够,它可能从:

[1][2]

搬到:

[1][2][3][空]

原来的那块内存会失效。

扩容倍数是多少

常见实现可能是:

1.5 倍
2 倍

但 C++ 标准不规定具体倍数。

所以面试不要说死:

vector 一定 2 倍扩容

更稳的说法是:

vector 通常按某个增长因子扩容,例如 1.5 倍或 2 倍,具体取决于标准库实现。

为什么 push_back 是均摊 O(1)

大多数 push_back

容量够,直接放到末尾,O(1)

偶尔一次扩容:

申请新内存,搬所有旧元素,O(n)

但扩容不是每次都发生。 平均摊下来,push_back 是:

c
amortized O(1)

也就是均摊常数时间。

中间插入和删除为什么慢

c
vector<int> v = {1, 2, 3, 4, 5};

v.insert(v.begin() + 2, 99);

插入后:

c
[1][2][99][3][4][5]

为了保持连续存储,3、4、5 都要往后移动。

所以中间插入复杂度是:

c
O(n)

删除也是:

c
v.erase(v.begin() + 2);

后面的元素要往前补位,也是:

c
O(n)

迭代器、指针、引用失效

这是 vector 高频坑。

c
vector<int> v;

v.push_back(1);
v.push_back(2);

int* p = &v[0];

// 如果这次 push_back 导致扩容
v.push_back(3);

// p 可能已经失效
// 因为旧内存已经被释放

扩容后:

旧 data 指向的内存没了
新 data 指向另一块更大的内存

所以这些都可能失效:

指针
引用
迭代器

常见规则:

发生扩容:全部迭代器、引用、指针失效。
未扩容 push_back:end 迭代器失效,已有元素引用通常仍有效。
insert/erase:插入/删除位置及其后面的迭代器和引用可能失效。

为什么 vector 缓存友好

因为连续内存适合 CPU cache。

遍历:

c
for (int x : v)
{
    // 处理 x
}

CPU 读到一个元素时,通常会顺便把后面的连续数据也加载进缓存。 所以 vector 遍历通常很快。

链表虽然插入删除节点方便,但节点分散在内存各处,缓存命中率差。 很多实际场景下,vectorlist 更快。

vector 和普通数组区别

普通数组:
大小固定,不能自动扩容。

vector:
动态大小,容量不够会自动扩容。

普通数组:

c
int arr[3] = {1, 2, 3};

vector:

c
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);

vector 更灵活,但扩容时有搬迁成本。

vector<T>vector<bool> 的坑

vector<bool> 是特殊版本。

它不是普通意义上的:

一个 bool 占一个元素位置

很多实现会把多个 bool 压缩到 bit 位里。

所以:

c
vector<bool> flags;

它的行为和普通 vector<T> 有些不同,比如元素访问返回的可能不是普通 bool&,而是代理对象。

面试可以补一句:

vector<bool> 是特化版本,不完全像普通 vector<T>,工程里要小心。

代码观察扩容

c
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<int> v;

    for (int i = 0; i < 10; i++)
    {
        v.push_back(i);

        cout << "size = " << v.size()
             << ", capacity = " << v.capacity()
             << endl;
    }

    return 0;
}

你会看到 capacity 不是每次都加 1,而是隔一段增长一次。 这就是为了避免每次 push_back 都重新分配内存。

实际开发建议

如果提前知道数量,用 reserve。
经常随机访问和遍历,用 vector 很合适。
经常在中间插入删除大量元素,vector 不一定合适。
不要长期保存 vector 元素指针后又继续 push_back。
需要传给 C API,可以用 v.data()。

例子:

c
vector<int> v;

v.reserve(1000); // 提前申请,减少扩容次数

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

这样比不 reserve 更稳定。

面试高分回答

TIP

std::vector 底层是连续动态数组。vector 对象本身通常保存指向堆上连续内存的指针,以及 size、capacity 相关信息。常见实现可以理解为 begin、end、end_capacity 三个指针,但具体布局是标准库实现细节。

因为元素连续存储,所以 vector 支持 O(1) 随机访问,遍历时缓存友好,也可以通过 data() 拿到连续内存给 C API 使用。

当 size 达到 capacity 后继续插入,vector 会重新申请一块更大的连续内存,然后把旧元素移动或拷贝过去,再释放旧内存。扩容倍数标准不固定,常见是 1.5 倍或 2 倍。因此 push_back 平均是均摊 O(1),但某次扩容可能是 O(n)。

缺点是中间插入和删除需要移动后续元素,所以是 O(n)。另外扩容会导致原来的迭代器、引用和指针失效,这是使用 vector 的高频坑。

最短记忆版

vector = 连续动态数组。
对象里存 data/size/capacity,元素在堆上连续内存。
随机访问 O(1),尾插均摊 O(1),中间插删 O(n)。
扩容会搬家,迭代器/指针/引用可能失效。

vector 扩容后迭代器为什么会失效?

一句话理解:vector 扩容后迭代器会失效,是因为 vector 底层是连续数组。容量不够时,它会重新申请一块更大的连续内存,把旧元素搬过去,再释放旧内存。旧迭代器还指向旧地址,所以就失效了。

vector-iterator-invalidation

零基础理解

你可以把 vector 想成一排连续座位。

一开始有 3 个座位:

c
[10][20][30]

你保存了一个迭代器:

c
auto it = v.begin();

它指向第一个座位,也就是 10

后来你继续 push_back,座位不够了。 vector 没法在原地变大,就会换一个更大的地方:

c
旧地方:[10][20][30]

新地方:[10][20][30][40][空][空]

然后旧地方被释放。 但你的旧迭代器 it 还记着旧地方的地址。

所以再用它就危险了。

代码例子

c
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<int> v;

    v.push_back(10);
    v.push_back(20);
    v.push_back(30);

    // 保存一个迭代器,指向第一个元素
    auto it = v.begin();

    // 保存扩容前的容量
    size_t oldCapacity = v.capacity();

    // 不断 push,直到触发扩容
    while (v.capacity() == oldCapacity)
    {
        v.push_back(100);
    }

    // 这里 it 很可能已经失效
    // cout << *it << endl; // 危险:未定义行为

    // 正确:扩容后重新获取迭代器
    it = v.begin();

    cout << *it << endl;

    return 0;
}

重点是这句:

c
cout << *it << endl;

如果 it 已经失效,再解引用就是未定义行为。 可能崩溃,也可能看起来正常,但都是错的。

为什么旧迭代器不能自动更新

因为迭代器通常可以理解成“指向元素位置的对象”。 对 vector 来说,它很多实现里接近一个原始指针。

比如:

it → 旧数组第 0 个元素地址

扩容后:

旧数组被释放
新数组地址变了

旧的 it 不会自动知道 vector 搬家了。 它还拿着旧地址。

所以它就成了:

c
悬空迭代器 dangling iterator

和悬空指针很像。

扩容时具体发生什么

当:

c
size == capacity

继续插入元素时,可能触发扩容。

大概流程:

c
1. 申请一块更大的连续内存
2. 把旧元素移动或拷贝到新内存
3. 在新内存里构造新元素
4. 析构旧内存里的元素
5. 释放旧内存
6. 更新 vector 内部 data / begin / end / capacity

也就是:

vector 自己知道新地址了
但你外面保存的旧迭代器不知道

哪些东西会失效

扩容后,这些通常都会失效:

旧迭代器
旧指针
旧引用

比如:

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

int* p = &v[0];
int& r = v[1];
auto it = v.begin();

v.push_back(4); // 如果触发扩容

// p 可能失效
// r 可能失效
// it 可能失效

原因一样: 它们都和旧内存地址有关。

没扩容时会不会失效

如果 push_back 没有触发扩容:

已有元素的引用、指针、迭代器通常仍然有效。

但是:

end() 迭代器会失效。

因为 end() 表示最后一个元素的下一个位置。 插入新元素后,end() 位置变了。

所以更稳的记法:

push_back 触发扩容:全部失效。
push_back 未触发扩容:end 失效,已有元素通常不失效。

insert 和 erase 也会导致失效

vector 中间插入:

c
vector<int> v = {1, 2, 3, 4};

auto it = v.begin() + 1; // 指向 2

v.insert(v.begin(), 99);

插入后,为了保持连续存储,元素要往后移动。 所以插入位置及其后面的迭代器、引用、指针都可能失效。

删除也是:

c
vector<int> v = {1, 2, 3, 4};

auto it = v.begin() + 2; // 指向 3

v.erase(v.begin());

删除后,后面的元素往前移动,原来的位置含义变了。

怎么避免迭代器失效问题

1. 提前 reserve

如果你大概知道要放多少元素:

c
vector<int> v;

// 提前申请容量,减少扩容次数
v.reserve(1000);

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

这样中途就不容易反复扩容。

2. 修改 vector 后重新获取迭代器

c
auto it = v.begin();

v.push_back(10);

// 不要继续用旧 it
it = v.begin();

3. 保存下标,不长期保存迭代器

有时保存下标更安全:

c
vector<int> v = {10, 20, 30};

size_t index = 1;

v.push_back(40);

// 扩容后重新用下标访问
cout << v[index] << endl;

但也要注意: 如果你做了 insert/erase,下标对应的元素也可能变了。

4. 不要保存 vector 元素地址后继续 push_back

危险:

c
int* p = &v[0];

v.push_back(100);

// p 可能失效

安全一点:

c
v.reserve(1000);

int* p = &v[0];

// 只要后续不超过 capacity,push_back 不触发扩容
// p 才比较稳定

但工程里还是要谨慎长期保存 vector 元素地址。

面试高分回答

NOTE

vector 的底层是连续动态数组。迭代器通常可以理解为指向数组中某个元素的位置。

当 vector 的 size 达到 capacity 后继续插入元素时,它需要扩容。扩容时会重新申请一块更大的连续内存,然后把旧元素移动或拷贝到新内存,再释放旧内存。

因为元素的内存地址整体变了,原来指向旧内存的迭代器、指针和引用就变成了悬空引用,所以会失效。继续使用这些旧迭代器属于未定义行为。

如果 push_back 没有触发扩容,已有元素的迭代器和引用通常还有效,但 end 迭代器会失效。insert 和 erase 则会使插入或删除位置及其后面的迭代器、引用失效。

实际开发中可以通过 reserve 提前申请容量,或者在修改 vector 后重新获取迭代器,避免长期保存 vector 元素地址。

最短记忆版

vector 扩容 = 重新申请大数组 + 搬元素 + 释放旧数组。
旧迭代器还指向旧数组,所以失效。
解决:reserve、重新获取迭代器、少长期保存元素地址。

listvector 怎么选择?

一句话理解: 默认优先选 vector。只有你确实需要频繁在中间插入/删除,并且已经拿到了目标位置的迭代器,还需要迭代器稳定时,才考虑 list

list-vs-vector-choice

先看底层区别

vector 底层是连续动态数组:

c
[10][20][30][40][50]

特点:

内存连续
随机访问快
遍历快
CPU 缓存友好
尾部插入快
中间插入删除要移动元素

list 底层是双向链表:

c
[A] <-> [B] <-> [C]

特点:

节点不连续
每个节点有 prev / next 指针
不支持下标随机访问
已知位置插入删除快
遍历缓存不友好
内存开销更大

为什么默认选 vector

很多人背复杂度会说:

vector 中间插删 O(n)
list 中间插删 O(1)
所以插删多用 list

这句话太粗了。

因为 list 的插入删除虽然是 O(1),但前提是:

你已经拿到了要插入/删除的位置迭代器。

如果你要先找位置:

c
auto it = find(lst.begin(), lst.end(), target);
lst.erase(it);

查找本身还是 O(n)。

而且 list 节点分散在内存里,CPU 缓存命中差。 实际工程里,中小规模数据下,vector 经常比 list 更快。

vector 适合什么场景

适合:

需要下标访问
经常遍历
经常排序
经常查找
主要在尾部 push_back
元素数量中小规模
希望内存紧凑
性能敏感,重视 CPU cache

例子:

c
#include <vector>
using namespace std;

struct Enemy
{
    int hp;
    int attack;
};

int main()
{
    vector<Enemy> enemies;

    enemies.reserve(1000);

    enemies.push_back({100, 20});
    enemies.push_back({150, 30});

    // 随机访问很快
    enemies[0].hp -= 10;

    // 遍历也很快,因为内存连续
    for (Enemy& enemy : enemies)
    {
        enemy.hp -= 1;
    }

    return 0;
}

游戏开发里,很多对象列表、组件列表、渲染数据、任务数据都更适合 vector

list 适合什么场景

适合:

频繁在中间插入删除
已经持有插入/删除位置的迭代器
不需要随机访问
需要插入删除后其他元素迭代器尽量稳定
需要 splice 把节点从一个 list 转移到另一个 list
元素对象很大,不想频繁搬动

例子:

c
#include <list>
using namespace std;

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

    auto it = nums.begin();
    ++it; // 指向 2

    // 已经知道位置时,插入很快
    nums.insert(it, 99);

    // 删除当前节点也很快
    nums.erase(it);

    return 0;
}

注意: list 快的是“已知位置后的插入删除”,不是“查找 + 插入删除”。

随机访问对比

vector

c
vector<int> v = {10, 20, 30};

int x = v[2]; // O(1)

list

c
list<int> lst = {10, 20, 30};

// list 没有 lst[2]
// 只能从头走到目标位置
auto it = lst.begin();
advance(it, 2); // O(n)

int x = *it;

所以如果你需要频繁通过下标访问,选 vector

插入删除对比

vector 中间插入:

c
vector<int> v = {1, 2, 3, 4};

v.insert(v.begin() + 1, 99);

结果:

c
[1][99][2][3][4]

2、3、4 都要往后移动,所以是 O(n)。

list 中间插入:

c
list<int> lst = {1, 2, 3, 4};

auto it = lst.begin();
++it;

lst.insert(it, 99);

只需要改几个指针:

c
前节点 next
新节点 prev/next
后节点 prev

如果已经有 it,插入是 O(1)。

迭代器失效对比

vector

扩容后,所有迭代器、指针、引用都可能失效。
insert/erase 后,位置及其后的迭代器可能失效。

list

插入不会让已有迭代器失效。
删除只会让被删除节点的迭代器失效。
其他节点迭代器仍然有效。

所以如果你需要长期保存迭代器,并且频繁插删,list 有优势。

内存开销对比

vector<int> 每个元素就是一个 int,连续放:

c
int int int int int

list<int> 每个节点除了 int,还要保存指针:

c
prev 指针
next 指针
int 数据

在 64 位系统里,一个指针通常 8 字节。 所以 list<int> 的节点开销可能远大于数据本身。

这也是为什么 list 不一定省。

缓存友好性

vector 连续:

CPU 读 v[0] 时,可能顺便把 v[1]、v[2]、v[3] 加载进缓存。

list 分散:

读完一个节点,要跟着 next 指针跳到另一个内存位置。

这会导致缓存不命中。 所以即使理论复杂度相同,vector 实际也可能更快。

选择建议

c
默认:vector
需要队头队尾操作:deque
需要有序查找:set / map
需要哈希查找:unordered_map / unordered_set
需要稳定迭代器 + 已知位置频繁插删:list

大多数时候:

先用 vector。
性能不满足,再测量和替换。

常见误区

1. 以为插删多就一定用 list。
2. 忘了 list 查找位置通常也是 O(n)。
3. 忽略 CPU cache,导致 list 理论好看但实际慢。
4. 用 list 存大量小对象,内存开销很大。
5. vector 扩容后继续用旧迭代器。
6. 不提前 reserve,导致 vector 频繁扩容。

面试高分回答

WARNING

vector 和 list 的选择不能只看插入删除复杂度。

vector 底层是连续动态数组,随机访问 O(1),遍历缓存友好,尾部 push_back 是均摊 O(1),所以大多数场景默认优先使用 vector。缺点是中间插入删除需要移动元素,扩容会导致迭代器、指针、引用失效。

list 底层是双向链表,不支持随机访问,查找位置需要 O(n),而且节点分散在内存中,缓存不友好,每个节点还有额外指针开销。它的优势是:在已经有目标迭代器的情况下,中间插入删除是 O(1),并且插入删除对其他节点的迭代器影响小。

所以如果主要是随机访问、遍历、排序、尾插,我选 vector。如果确实需要频繁在中间插删,已经持有位置迭代器,并且需要迭代器稳定,我才考虑 list。工程里还要结合数据规模和实际性能测试。

最短记忆版

默认 vector:连续内存,随机访问快,遍历快,缓存友好。
慎用 list:已知位置插删快,但查找慢、缓存差、内存开销大。
选择看访问模式,不只看 Big-O。

mapunordered_map 区别是什么?

一句话理解:map 是“自动排序的字典”,底层通常是红黑树;unordered_map 是“不排序但查找通常更快的字典”,底层是哈希表。

map-vs-unordered-map

零基础理解: 它们都用来存“键值对”:

c
// key 是名字,value 是分数
Tom -> 90
Bob -> 80
Alice -> 95

区别在于:

map 会按照 key 自动排序,比如字符串 key 会按字典序排列。 unordered_map 不保证顺序,它关心的是“怎么尽快根据 key 找到 value”。

底层区别:

map 底层通常是红黑树。 红黑树是一种自平衡二叉搜索树,所以数据天然有序,查找、插入、删除通常都是:

c
O(log n)

unordered_map 底层是哈希表。 它会把 key 通过哈希函数算成一个数字,再映射到桶里:

c
hash(key) -> 桶下标 -> 找到 value

平均情况下查找、插入、删除是:

c
O(1)

但是如果哈希冲突很多,最坏可能退化到:

c
O(n)

代码对比:

c
#include <iostream>
#include <map>
#include <unordered_map>
using namespace std;

int main() {
    map<string, int> orderedScores;
    unordered_map<string, int> fastScores;

    orderedScores["Tom"] = 90;
    orderedScores["Bob"] = 80;
    orderedScores["Alice"] = 95;

    fastScores["Tom"] = 90;
    fastScores["Bob"] = 80;
    fastScores["Alice"] = 95;

    cout << "map 遍历结果:" << endl;
    for (auto& item : orderedScores) {
        // map 会按照 key 自动排序
        cout << item.first << " = " << item.second << endl;
    }

    cout << "unordered_map 遍历结果:" << endl;
    for (auto& item : fastScores) {
        // unordered_map 的遍历顺序不保证固定
        cout << item.first << " = " << item.second << endl;
    }

    return 0;
}

你可能看到:

c
map:
Alice = 95
Bob = 80
Tom = 90

因为它按 key 排序。

unordered_map 可能是:

c
Tom = 90
Alice = 95
Bob = 80

也可能下次顺序又不一样,因为它不负责排序。

什么时候用 map?

当你需要“有序”时,用 map

c
map<int, string> students;

students[1003] = "Tom";
students[1001] = "Alice";
students[1002] = "Bob";

// 遍历时会自动按学号从小到大输出
for (auto& p : students) {
    cout << p.first << " " << p.second << endl;
}

适合场景:

key 必须有序。 需要范围查询,比如找 [10, 50] 之间的 key。 需要 lower_boundupper_bound。 希望性能稳定,最坏也大致是 O(log n)

什么时候用 unordered_map?

当你只关心“根据 key 快速查值”,不关心顺序时,用 unordered_map

c
unordered_map<string, int> bag;

// 统计每种道具数量
bag["coin"]++;
bag["coin"]++;
bag["sword"]++;

cout << bag["coin"] << endl; // 2

适合场景:

统计频率。 缓存数据。 根据 id 快速找对象。 游戏里根据角色 ID、配置 ID、资源名查数据。 不需要排序,只要快。

重要区别表:

对比点mapunordered_map
底层结构红黑树哈希表
是否有序有序无序
查找复杂度O(log n)平均 O(1),最坏 O(n)
插入复杂度O(log n)平均 O(1)
范围查询支持不适合
内存占用节点有左右指针、颜色桶数组 + 节点,通常也不低
key 要求能比较大小能计算 hash,能判断相等
常用场景有序、范围、稳定快速查找、统计、缓存

key 类型要求不同:

map 需要 key 能比较大小:

c
map<int, string> m; // int 可以比较大小,没问题
map<string, int> m2; // string 也可以比较大小,没问题

本质上它需要类似:

c
a < b

unordered_map 需要 key 能哈希,并且能判断相等:

c
unordered_map<string, int> m; // string 标准库已经支持 hash

本质上它需要:

c
hash(key)
a == b

面试容易加分的点:

不要只说 unordered_map 一定比 map 快。更准确的说法是:

unordered_map 平均查找是 O(1),通常比 map 快;但它依赖哈希函数质量,如果哈希冲突严重,最坏会退化到 O(n)。而 map 是红黑树,查找是稳定的 O(log n),并且天然有序,适合范围查询和有序遍历。

还有一个坑:rehash。

unordered_map 元素越来越多时,桶不够用了,会扩容并重新分配桶,这叫 rehash

c
unordered_map<int, string> m;

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

// 提前预留空间,减少 rehash 次数
m.reserve(1000);

rehash 可能导致迭代器失效,所以如果你一边保存迭代器,一边大量插入,要小心。

面试回答模板:

TIP

mapunordered_map 都是 C++ 的关联容器,都是存 key-value。map 底层通常是红黑树,key 自动有序,查找、插入、删除是 O(log n),适合有序遍历和范围查询。unordered_map 底层是哈希表,不保证顺序,平均查找插入是 O(1),适合根据 key 快速查值,比如统计频率、缓存、ID 映射。但 unordered_map 依赖哈希函数,哈希冲突严重时可能退化,而且 rehash 可能导致迭代器失效。所以默认追求查找速度用 unordered_map,需要顺序或范围查询用 map

setunordered_set 区别是什么?

一句话理解:setunordered_set 都是“集合”,只存 key,不存 value,并且元素不能重复。区别是:set 会自动排序,unordered_set 不排序但平均查找更快。

set-vs-unordered-set

零基础理解: 假设你要存一批玩家 ID:

1003
1001
1002
1001

集合的特点是:重复的只保留一份。

所以最终是:

1001
1002
1003

或者无序地存成:

c
1002
1001
1003

如果你用 set,它会自动按大小排序。 如果你用 unordered_set,它只保证“能找到、不会重复”,但不保证顺序。

set 是什么?

std::set 是有序集合。

c
#include <iostream>
#include <set>
using namespace std;

int main() {
    set<int> ids;

    ids.insert(1003);
    ids.insert(1001);
    ids.insert(1002);
    ids.insert(1001); // 重复插入,set 不会保存第二份

    for (int id : ids) {
        // 输出时会自动从小到大
        cout << id << endl;
    }

    return 0;
}

输出:

c
1001
1002
1003

set 底层通常是红黑树。红黑树是一种自平衡二叉搜索树,所以它能让元素一直保持有序。

查找、插入、删除复杂度通常是:

c
O(log n)

unordered_set 是什么?

std::unordered_set 是无序集合。

c
#include <iostream>
#include <unordered_set>
using namespace std;

int main() {
    unordered_set<int> ids;

    ids.insert(1003);
    ids.insert(1001);
    ids.insert(1002);
    ids.insert(1001); // 重复插入,也不会保存第二份

    for (int id : ids) {
        // 输出顺序不保证,可能不是从小到大
        cout << id << endl;
    }

    return 0;
}

它底层是哈希表。

大概过程是:

元素 -> hash(元素) -> 桶下标 -> 放进桶里

平均查找、插入、删除复杂度是:

O(1)

但如果哈希冲突很多,最坏可能退化成:

O(n)

核心区别表:

对比点setunordered_set
底层结构红黑树哈希表
是否排序自动排序不保证顺序
是否允许重复不允许不允许
查找复杂度O(log n)平均 O(1)
最坏复杂度O(log n)可能 O(n)
范围查询支持不适合
key 要求能比较大小能 hash,并能判断相等
常见用途有序去重、范围查询快速判重、快速判断存在

什么时候用 set?

当你需要“有序”时,用 set

比如:排行榜、按 ID 排序输出、找某个范围内的数据。

c
set<int> scores = {90, 70, 80, 100};

// 找第一个 >= 80 的元素
auto it = scores.lower_bound(80);

if (it != scores.end()) {
    cout << *it << endl; // 80
}

set 很适合做这种范围相关操作:

c
lower_bound()
upper_bound()

什么时候用 unordered_set?

当你只想快速判断“有没有这个东西”时,用 unordered_set

c
#include <iostream>
#include <unordered_set>
using namespace std;

int main() {
    unordered_set<string> loadedResources;

    loadedResources.insert("hero.png");
    loadedResources.insert("monster.png");

    string name = "hero.png";

    if (loadedResources.find(name) != loadedResources.end()) {
        // 找到了,说明这个资源已经加载过
        cout << "资源已经加载" << endl;
    } else {
        cout << "资源还没加载" << endl;
    }

    return 0;
}

游戏里很常见:

c
unordered_set<int> aliveMonsterIds;     // 记录还活着的怪物 ID
unordered_set<string> loadedAssetNames; // 记录已经加载过的资源名
unordered_set<int> unlockedSkillIds;    // 记录已解锁技能

因为你通常只关心:

这个 ID 在不在集合里?

不关心它排第几。

insert 的返回值也常考:

c
set<int> s;

auto result = s.insert(10);

if (result.second) {
    // second 为 true,说明插入成功
    cout << "第一次插入 10" << endl;
} else {
    // second 为 false,说明 10 已经存在
    cout << "10 已经存在" << endl;
}

setunordered_set 都可以这样判断是否真的插入成功。

容易踩的坑:不要随便修改集合里的元素。

因为集合里的元素本身就是 key。

对于 set 来说,元素的位置依赖它的大小关系。 对于 unordered_set 来说,元素的位置依赖它的 hash 值。

如果你强行修改元素,容器内部结构可能就乱了。所以一般不能直接改集合中的元素,要先删除,再插入新的。

c
set<int> s = {1, 2, 3};

// 正确做法:先删旧值,再插新值
s.erase(2);
s.insert(20);

NOTE

面试高分回答:setunordered_set 都是用来存不重复元素的关联容器。set 底层通常是红黑树,会按照 key 自动排序,所以查找、插入、删除是稳定的 O(log n),并且支持有序遍历和范围查询。unordered_set 底层是哈希表,不保证遍历顺序,平均查找、插入、删除是 O(1),适合快速判重和判断元素是否存在。但它依赖哈希函数质量,冲突严重时可能退化,并且 rehash 可能导致迭代器失效。所以需要顺序或范围查询用 set,只追求快速存在性判断一般用 unordered_set

deque 适合什么场景?

一句话理解:deque 适合“头部和尾部都要频繁插入、删除”的场景。它的全名是 double-ended queue,意思就是“双端队列”。

deque-scenarios

零基础理解:vector 像一整排连续座位,只在最后加人很方便;如果老是在最前面加人,就要把后面的人整体挪位置,很慢。

deque 更像一节一节车厢拼起来的队伍。前面可以加一节,后面也可以加一节,所以它两头操作都很方便。

c
#include <iostream>
#include <deque>
using namespace std;

int main() {
    deque<int> dq;

    dq.push_back(10);   // 尾部插入
    dq.push_back(20);   // 尾部插入
    dq.push_front(5);   // 头部插入

    cout << dq.front() << endl; // 5,访问头部
    cout << dq.back() << endl;  // 20,访问尾部

    dq.pop_front(); // 删除头部
    dq.pop_back();  // 删除尾部

    return 0;
}

底层原理:deque 不是像 vector 那样一整块连续内存。

它通常是:

中控数组 + 多个连续小块

可以粗略理解成:

c
map 指针数组
   |
   +-- 数据块 1: [1][2][3][4]
   +-- 数据块 2: [5][6][7][8]
   +-- 数据块 3: [9][10][11][12]

所以它的特点是:

c
push_front()  快
push_back()   快
pop_front()   快
pop_back()    快
operator[]    支持随机访问

但是它的内存不是整体连续的,所以缓存友好程度通常不如 vector

适合场景 1:普通队列

比如 BFS、任务队列、消息队列,经常是后面进、前面出。

c
#include <iostream>
#include <deque>
using namespace std;

int main() {
    deque<int> tasks;

    tasks.push_back(1); // 新任务从尾部进入
    tasks.push_back(2);
    tasks.push_back(3);

    while (!tasks.empty()) {
        int task = tasks.front(); // 从头部取出最早的任务
        tasks.pop_front();        // 删除已经处理的任务

        cout << "处理任务: " << task << endl;
    }

    return 0;
}

其实 std::queue 默认底层容器就是 deque

c
#include <queue>
using namespace std;

queue<int> q; // 默认内部通常用 deque<int>

适合场景 2:滑动窗口

比如算法题里常见的“滑动窗口最大值”,deque 非常经典。

c
#include <iostream>
#include <vector>
#include <deque>
using namespace std;

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    deque<int> dq;          // 存下标,不直接存值
    vector<int> result;

    for (int i = 0; i < nums.size(); i++) {
        // 如果队头下标已经滑出窗口,就删掉
        if (!dq.empty() && dq.front() <= i - k) {
            dq.pop_front();
        }

        // 保持队列从大到小
        // 如果新来的数更大,后面比它小的数就没用了
        while (!dq.empty() && nums[dq.back()] <= nums[i]) {
            dq.pop_back();
        }

        // 把当前下标加入队尾
        dq.push_back(i);

        // 当窗口长度达到 k 后,队头就是当前窗口最大值
        if (i >= k - 1) {
            result.push_back(nums[dq.front()]);
        }
    }

    return result;
}

这里 deque 的价值很明显: 队头要删过期元素,队尾要删没用元素,两头都在频繁操作。

适合场景 3:需要头尾都能加删的缓冲区

比如:

c
deque<string> logs;

logs.push_back("玩家登录");
logs.push_back("玩家进入战斗");

// 如果想在最前面补一条高优先级日志
logs.push_front("系统初始化完成");

这种“前后都可能插入”的需求,用 dequevector 更自然。

不适合什么场景?

deque 不适合大量中间插入删除。

c
deque<int> dq = {1, 2, 3, 4, 5};

// 在中间插入,通常需要移动元素,不是 deque 的优势
dq.insert(dq.begin() + 2, 99);

如果你经常在中间插入删除,而且已经有目标位置的迭代器,可能考虑 list。 如果你主要是尾插、遍历、随机访问,通常优先考虑 vector

deque 和 vector 对比:

对比点vectordeque
底层一整块连续数组多个连续小块
尾部插入
头部插入慢,需要移动大量元素
随机访问快,缓存友好支持,但通常略慢
中间插入删除也不适合
内存连续性连续不完全连续
适合场景尾插、遍历、随机访问两头频繁插入删除

CAUTION

面试高分回答:deque 是双端队列,适合头部和尾部都频繁插入删除的场景,比如 BFS 队列、任务队列、滑动窗口、单调队列等。它底层通常不是一整块连续内存,而是由多个固定大小的缓冲区组成,再通过一个中控数组管理这些缓冲区。所以它支持 push_frontpush_backpop_frontpop_back 的高效操作,也支持下标随机访问。但由于内存不完全连续,缓存友好性一般不如 vector,中间插入删除也不是它的优势。默认只需要尾插和遍历时用 vector,两头都要频繁操作时用 deque

迭代器失效有哪些情况?

一句话理解: 迭代器失效就是:你手里的迭代器原来指向某个元素,但容器发生了插入、删除、扩容、rehash 等操作后,它指向的位置不再可靠了,再用它就可能出错。

iterator-invalidation-cases

零基础理解: 迭代器可以先理解成“指向容器里某个元素的指针”。

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

auto it = v.begin(); // it 指向 1

如果容器内部内存没变,it 还能用。 如果容器扩容、搬家、删除元素,那 it 可能还指着旧位置,这就叫迭代器失效。

最常见情况 1:vector 扩容导致全部失效

vector 底层是一整块连续数组。容量不够时,它会申请一块更大的新内存,把旧元素搬过去,再释放旧内存。

c
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v;
    v.push_back(1);
    v.push_back(2);

    auto it = v.begin(); // 指向第一个元素 1

    // 如果这次 push_back 导致 vector 扩容,
    // 原来的内存会被释放,it 就失效了
    v.push_back(3);

    // 危险:it 可能已经失效
    // cout << *it << endl;

    // 正确:容器变化后重新获取迭代器
    it = v.begin();
    cout << *it << endl;

    return 0;
}

vector 规则要记住:

扩容:所有迭代器、指针、引用都失效
没扩容的 push_back:以前元素的迭代器一般还有效,但 end() 会失效
insert:插入位置以及后面的迭代器失效;如果扩容则全部失效
erase:删除位置以及后面的迭代器失效

最常见情况 2:vector erase 后继续用旧迭代器

错误写法:

c
vector<int> v = {1, 2, 3, 4, 5};

for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 3) {
        v.erase(it); // it 被 erase 后已经失效
        // 循环结尾还会 ++it,危险
    }
}

正确写法:

c
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v = {1, 2, 3, 4, 5};

    for (auto it = v.begin(); it != v.end(); ) {
        if (*it == 3) {
            // erase 会返回下一个有效迭代器
            it = v.erase(it);
        } else {
            ++it;
        }
    }

    for (int x : v) {
        cout << x << " ";
    }

    return 0;
}

这个写法非常重要,面试和实际项目都常用。

情况 3:list 删除时,只有被删元素失效

list 是链表,每个元素是单独节点。插入新节点通常不会影响其他节点。

c
#include <iostream>
#include <list>
using namespace std;

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

    auto it1 = nums.begin(); // 指向 1
    auto it2 = next(nums.begin(), 2); // 指向 3

    nums.insert(nums.begin(), 100);

    // list 插入一般不会让已有迭代器失效
    cout << *it1 << endl; // 仍然可以用,输出 1
    cout << *it2 << endl; // 仍然可以用,输出 3

    nums.erase(it2);

    // it2 指向的元素已经被删除,所以 it2 失效
    // cout << *it2 << endl; // 危险

    return 0;
}

list 规则:

c
insert:其他迭代器不失效
erase:只有被删除元素的迭代器失效

情况 4:map / set 删除时,被删元素失效

mapset 底层通常是红黑树,属于节点型容器。

c
#include <iostream>
#include <map>
using namespace std;

int main() {
    map<int, string> m;

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

    auto it1 = m.find(1);
    auto it2 = m.find(2);

    m.erase(it2);

    cout << it1->second << endl; // 安全,it1 还有效

    // it2 指向的节点已经被删除,不能再用
    // cout << it2->second << endl; // 危险

    return 0;
}

map / set 规则:

insert:一般不影响已有迭代器
erase:只有被删除元素的迭代器失效

情况 5:unordered_map / unordered_set rehash 导致失效

哈希容器底层是哈希表。元素多了以后,桶不够用,会重新分配桶,这叫 rehash

c
#include <iostream>
#include <unordered_map>
using namespace std;

int main() {
    unordered_map<int, string> m;

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

    auto it = m.find(1);

    // 插入大量元素,可能触发 rehash
    for (int i = 3; i < 1000; i++) {
        m[i] = "X";
    }

    // it 可能已经因为 rehash 失效
    // cout << it->second << endl; // 危险

    // 正确:重新 find
    it = m.find(1);
    if (it != m.end()) {
        cout << it->second << endl;
    }

    return 0;
}

unordered_map / unordered_set 规则:

c
insert:如果没有 rehash,一般不影响已有迭代器
insert:如果触发 rehash,所有迭代器失效
erase:被删除元素的迭代器失效
rehash / reserve:通常会让所有迭代器失效

想减少 rehash,可以提前:

c
unordered_map<int, string> m;

// 提前预留空间,减少扩容和 rehash
m.reserve(1000);

情况 6:deque 的规则比较绕

deque 是双端队列,底层不是一整块连续内存,而是多个小块组成。

它的特点是两头插入删除很快:

c
deque<int> dq;

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

但是它的迭代器失效规则比 vectorlist 更容易记混。

大致面试记法:

c
push_front / push_back:可能导致迭代器失效,尤其 end() 要小心
pop_front / pop_back:被删除元素的迭代器失效
中间 insert / erase:通常会导致大量甚至全部迭代器失效

所以 deque 里不要长期保存迭代器,容器修改后尽量重新获取。

常见容器总结表:

容器插入是否导致失效删除是否导致失效
vector扩容则全部失效;不扩容时插入位置及后面失效删除位置及后面失效
string类似 vector类似 vector
deque头尾操作也要小心;中间插入通常影响很大中间删除通常影响很大
list一般不影响其他迭代器只有被删元素失效
map / set一般不影响其他迭代器只有被删元素失效
unordered_map / unordered_setrehash 则全部迭代器失效被删元素失效

最安全的 erase 写法:

c
for (auto it = container.begin(); it != container.end(); ) {
    if (需要删除) {
        // erase 返回删除后下一个有效位置
        it = container.erase(it);
    } else {
        ++it;
    }
}

不要这样写:

c
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 3) {
        v.erase(it); // erase 后 it 已经不可靠
    }
}

因为循环最后还会 ++it,但 it 已经失效了。

IMPORTANT

面试高分回答: 迭代器失效本质上是容器结构变化后,原来的迭代器不再指向有效位置。连续内存容器比如 vectorstring,扩容会重新分配内存,导致所有迭代器、指针、引用失效;插入或删除会导致操作位置及其后的迭代器失效。节点型容器比如 listmapset,插入一般不影响已有迭代器,删除只会让被删元素的迭代器失效。哈希容器比如 unordered_mapunordered_set,如果插入触发 rehash,所有迭代器会失效;删除时被删元素失效。deque 比较特殊,头尾操作很快,但中间插入删除容易导致大量迭代器失效。实际写代码时,erase 后要使用返回的新迭代器,容器修改后不要继续使用旧的 end() 或旧迭代器。

sort 底层大概是什么?

一句话理解:std::sort 底层通常不是单纯的快速排序,而是“内省排序” introsort:快速排序 + 堆排序 + 插入排序。

std-sort-underlying

零基础理解: 你可以把 std::sort 理解成一个很聪明的排序工具:

平时它用类似快速排序的方法,因为快。 如果发现快速排序递归太深,可能要退化,它就切换成堆排序兜底。 如果只剩很小一段数组,它用插入排序收尾,因为小数据量下插入排序常数小,反而划算。

也就是:

std::sort ≈ 快速排序 + 堆排序 + 插入排序

为什么不是只用快速排序?

快速排序平均很快:

平均时间复杂度:O(n log n)

但是如果 pivot 选得很差,比如每次都把数组分得特别不均匀,最坏可能退化成:

最坏时间复杂度:O(n^2)

std::sort 为了避免这种情况,主流实现会用 introsort

introsort 是什么?

introsort 中文常叫“内省排序”。

它的思路是:

一开始:用快速排序
如果递归深度太深:切换成堆排序
如果区间很小:用插入排序

这样它既想要快速排序的平均性能,又想要堆排序的最坏情况保障。

大概流程:

sort(array):
    如果区间很大:
        先用快速排序思想 partition
        如果递归太深:
            改用堆排序
    如果区间很小:
        用插入排序收尾

注意:这只是帮助理解的伪代码,不是标准库源码。

代码怎么用?

c
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> nums = {5, 2, 9, 1, 7};

    // 默认从小到大排序
    sort(nums.begin(), nums.end());

    for (int x : nums) {
        cout << x << " ";
    }

    return 0;
}

输出:

c
1 2 5 7 9

自定义排序:

c
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    vector<int> nums = {5, 2, 9, 1, 7};

    // 从大到小排序
    sort(nums.begin(), nums.end(), [](int a, int b) {
        // 返回 true 表示 a 应该排在 b 前面
        return a > b;
    });

    for (int x : nums) {
        cout << x << " ";
    }

    return 0;
}

输出:

c
9 7 5 2 1

排序结构体:

c
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

struct Player {
    string name;
    int score;
};

int main() {
    vector<Player> players = {
        {"Tom", 90},
        {"Alice", 95},
        {"Bob", 80}
    };

    sort(players.begin(), players.end(), [](const Player& a, const Player& b) {
        // 按分数从高到低排序
        return a.score > b.score;
    });

    for (const Player& p : players) {
        cout << p.name << " " << p.score << endl;
    }

    return 0;
}

std::sort 的特点:

特点说明
平均复杂度O(n log n)
主流底层introsort
是否稳定不稳定
是否原地排序基本是原地排序
需要什么迭代器随机访问迭代器
常用容器vectorarraydeque、普通数组
不适合list 不能直接用 std::sort

为什么 list 不能用 std::sort?

std::sort 需要随机访问迭代器。

也就是说它需要能快速做这种操作:

c
it + 5
it - 3
middle = begin + length / 2

vector 可以,因为它底层是连续数组。

c
vector<int> v = {3, 1, 2};
sort(v.begin(), v.end()); // 可以

list 是链表,不能快速跳到第几个元素。

c
list<int> l = {3, 1, 2};

// sort(l.begin(), l.end()); // 错误,list 迭代器不是随机访问迭代器

l.sort(); // 正确,list 有自己的 sort

std::sort 稳定吗?

不稳定。

“不稳定”的意思是:如果两个元素排序关键字相等,它们原来的相对顺序不保证保持。

比如:

c
struct Student {
    string name;
    int score;
};

原数据:

c
Tom   90
Bob   90
Alice 80

如果按分数排序,TomBob 都是 90。 使用 std::sort 后,不保证 Tom 仍然在 Bob 前面。

如果你需要稳定排序,要用:

c
stable_sort(v.begin(), v.end(), cmp);

比较函数的坑:

比较函数必须满足严格弱序。

简单说,不要写这种:

c
sort(nums.begin(), nums.end(), [](int a, int b) {
    return a <= b; // 错误
});

正确写法:

c
sort(nums.begin(), nums.end(), [](int a, int b) {
    return a < b; // 正确
});

为什么 <= 错? 因为当 a == b 时,a <= b 是 true,b <= a 也是 true。排序器会被这种逻辑搞乱,可能出现未定义行为。

IMPORTANT

面试高分回答:std::sort 标准并不强制规定具体实现,但主流 STL 一般使用 introsort,也就是内省排序。它一开始使用快速排序思想做 partition,因为快速排序平均性能好、缓存友好;当递归深度过深时,为了避免快速排序退化到 O(n^2),会切换到堆排序,把最坏复杂度控制在 O(n log n);对于小区间,通常用插入排序收尾,因为小数据量下插入排序常数开销低。std::sort 是不稳定排序,需要随机访问迭代器,所以常用于 vector、数组、deque,不能直接用于 listlist 要用自己的 list.sort()

priority_queue 底层是什么?

一句话理解:priority_queue 底层默认是 vector,再用“堆”来维护优先级。默认是大根堆,也就是最大的元素先出来。

priority-queue-underlying

零基础理解: 普通队列 queue 是“先来先服务”:

先进去的,先出来

priority_queue 是“谁优先级高,谁先出来”:

分数最高的,先出来
血量最低的,先处理
距离最短的,先处理

比如:

c
priority_queue<int> pq;

pq.push(30);
pq.push(10);
pq.push(50);

cout << pq.top() << endl; // 50

虽然 50 不是第一个插入的,但它最大,所以它先出来。

底层是什么?

priority_queue 是一个容器适配器。

意思是:它自己不是从零实现一个新容器,而是套在别的容器上,对外提供一套“优先队列”的接口。

默认大概可以理解成:

c
priority_queue<T> = vector<T> + heap

默认底层容器是:

c
vector<T>

默认堆规则是:

大根堆

所以默认:

top() 取最大值

什么是堆?

堆不是“内存里的堆区”那个堆。这里的堆是一种数据结构。

大根堆满足:

父节点 >= 子节点

比如:

c
        90
      /    \
    70      60
   /  \    /  \
 40   50  20  10

最大值永远在最上面,所以 top() 很快:

c
O(1)

但注意,堆不是完全有序。

它只保证父节点比子节点优先级高,不保证整个数组从大到小排好。

priority_queue 的常用操作:

c
#include <iostream>
#include <queue>
using namespace std;

int main() {
    priority_queue<int> pq;

    pq.push(30); // 插入元素
    pq.push(10);
    pq.push(50);

    cout << pq.top() << endl; // 查看堆顶,输出 50

    pq.pop(); // 删除堆顶,也就是删除 50

    cout << pq.top() << endl; // 输出 30

    return 0;
}

复杂度:

操作复杂度原因
top()O(1)堆顶就在数组第一个位置
push()O(log n)插入后要向上调整
pop()O(log n)删除堆顶后要向下调整
size()O(1)直接记录大小
empty()O(1)判断是否为空

push 底层大概发生什么?

c
pq.push(80);

底层可以粗略理解成:

1. 先把 80 放到 vector 最后
2. 然后不断和父节点比较
3. 如果 80 比父节点优先级更高,就往上交换
4. 最后重新满足堆规则

类似:

c
vector.push_back(value);
push_heap(vector.begin(), vector.end());

pop 底层大概发生什么?

c
pq.pop();

底层可以粗略理解成:

1. 把堆顶元素和最后一个元素交换
2. 删除最后一个元素
3. 把新的堆顶向下调整
4. 重新满足堆规则

类似:

c
pop_heap(vector.begin(), vector.end());
vector.pop_back();

默认是大根堆:

c
#include <iostream>
#include <queue>
using namespace std;

int main() {
    priority_queue<int> pq;

    pq.push(3);
    pq.push(1);
    pq.push(5);

    while (!pq.empty()) {
        cout << pq.top() << " "; // 每次取最大值
        pq.pop();
    }

    return 0;
}

输出:

c
5 3 1

怎么写小根堆?

小根堆就是最小的元素先出来。

c
#include <iostream>
#include <queue>
#include <vector>
#include <functional>
using namespace std;

int main() {
    // 小根堆:最小值优先
    priority_queue<int, vector<int>, greater<int>> pq;

    pq.push(3);
    pq.push(1);
    pq.push(5);

    while (!pq.empty()) {
        cout << pq.top() << " "; // 每次取最小值
        pq.pop();
    }

    return 0;
}

输出:

c
1 3 5

这里三个模板参数分别是:

c
priority_queue<元素类型, 底层容器, 比较规则>

也就是:

c
priority_queue<int, vector<int>, greater<int>>

表示:

c
元素是 int
底层用 vector<int>
比较规则用 greater<int>

自定义结构体怎么用?

比如游戏里有任务,优先处理优先级高的任务:

c
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

struct Task {
    int id;
    int priority;
};

struct CompareTask {
    bool operator()(const Task& a, const Task& b) const {
        // 返回 true 表示 a 的优先级比 b 低
        // priority_queue 会把“更不低”的放到 top
        return a.priority < b.priority;
    }
};

int main() {
    priority_queue<Task, vector<Task>, CompareTask> pq;

    pq.push({1, 10});
    pq.push({2, 50});
    pq.push({3, 30});

    while (!pq.empty()) {
        Task t = pq.top();
        pq.pop();

        cout << "处理任务 id = " << t.id
             << ", priority = " << t.priority << endl;
    }

    return 0;
}

输出顺序是:

c
priority = 50
priority = 30
priority = 10

比较器最容易绕的地方:

很多初学者会疑惑:

c
return a.priority < b.priority;

为什么这是大优先级先出来?

你可以这样记:

priority_queue 里,比较器返回 true 的那个,会被认为“优先级更低,应该往后排”。

所以:

c
return a.priority < b.priority;

意思是:

c
a.priority 小于 b.priority 时,a 优先级更低

因此大的 priority 会在前面。

priority_queue 不能做什么?

它不能像 vector 一样随便遍历出有序结果。

c
priority_queue<int> pq;

底层虽然是 vector,但这个 vector 只满足堆结构,不是完全有序数组。

如果你想从大到小拿结果,要反复:

c
while (!pq.empty()) {
    cout << pq.top() << endl;
    pq.pop();
}

适合什么场景?

priority_queue 适合“每次都要拿当前最优元素”的场景。

常见例子:

任务调度:优先级高的任务先执行
排行榜:取当前最高分
寻路算法:Dijkstra / A*
合并多个有序数组
Top K 问题
事件系统:时间最早的事件先触发

例如 Top K:

c
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

int main() {
    vector<int> nums = {5, 1, 9, 3, 7, 2};
    int k = 3;

    // 小根堆,堆里只保留最大的 k 个数
    priority_queue<int, vector<int>, greater<int>> pq;

    for (int x : nums) {
        pq.push(x);

        // 如果超过 k 个,就弹出最小的
        // 剩下的就是当前最大的 k 个
        if (pq.size() > k) {
            pq.pop();
        }
    }

    cout << "第 " << k << " 大的数是: " << pq.top() << endl;

    return 0;
}

NOTE

面试高分回答:priority_queue 是 C++ STL 的容器适配器,默认底层容器是 vector,并在这个 vector 上维护堆结构。默认比较器是 less<T>,所以默认是大根堆,top() 返回当前最大元素。它不是完全排序的容器,只保证堆顶元素优先级最高。插入时先放到底层容器末尾,再向上调整堆,复杂度是 O(log n);删除时删除堆顶,然后把元素向下调整,复杂度也是 O(log n);访问堆顶是 O(1)。如果要小根堆,可以写 priority_queue<int, vector<int>, greater<int>>。它适合每次都需要取当前最大或最小元素的场景,比如 Top K、任务调度、Dijkstra、A* 等。

如何自定义哈希函数?

一句话理解: 自定义哈希函数就是告诉 unordered_map / unordered_set:我的自定义类型应该怎么转换成一个 size_t 数字,用来决定放进哪个哈希桶。

custom-hash-function

零基础理解:unordered_mapunordered_set 底层是哈希表。它们查找元素时大概分两步:

1. 用 hash(key) 算出一个数字,决定去哪个桶找
2. 在桶里用 == 判断是不是目标 key

所以自定义哈希时,通常要提供两个东西:

哈希函数:怎么把 key 变成 size_t
相等判断:怎么判断两个 key 是不是同一个

例子:自定义一个坐标 Point

假设我们想把二维坐标放进 unordered_set

c
Point(1, 2)
Point(3, 4)
Point(1, 2)

重复坐标只保留一份。

c
#include <iostream>
#include <unordered_set>
using namespace std;

struct Point {
    int x;
    int y;

    // 判断两个 Point 是否相等
    bool operator==(const Point& other) const {
        return x == other.x && y == other.y;
    }
};

struct PointHash {
    size_t operator()(const Point& p) const {
        // 分别计算 x 和 y 的哈希值
        size_t h1 = hash<int>()(p.x);
        size_t h2 = hash<int>()(p.y);

        // 把两个哈希值混合起来
        // 这里用一个常见的 hash combine 写法
        return h1 ^ (h2 << 1);
    }
};

int main() {
    unordered_set<Point, PointHash> points;

    points.insert({1, 2});
    points.insert({3, 4});
    points.insert({1, 2}); // 重复,不会插入第二份

    cout << points.size() << endl; // 输出 2

    return 0;
}

这里:

c
PointHash

负责算哈希。

operator==

负责判断两个 Point 是否相等。

最重要规则:相等的 key,哈希值必须相同

必须满足:

c
如果 a == b,那么 hash(a) 必须 == hash(b)

但是反过来不要求。

也就是说:

c
hash(a) == hash(b)

不一定代表:

c
a == b

因为可能发生哈希冲突。

哈希冲突就是:两个不同的 key 被分到了同一个桶里。

方式 1:用函数对象,最常见

c
#include <iostream>
#include <unordered_map>
using namespace std;

struct GridPos {
    int row;
    int col;

    // 两个格子位置相同,才认为是同一个 key
    bool operator==(const GridPos& other) const {
        return row == other.row && col == other.col;
    }
};

struct GridPosHash {
    size_t operator()(const GridPos& pos) const {
        size_t h1 = hash<int>()(pos.row);
        size_t h2 = hash<int>()(pos.col);

        // 混合 row 和 col 的哈希值
        return h1 ^ (h2 << 1);
    }
};

int main() {
    unordered_map<GridPos, string, GridPosHash> gridNames;

    gridNames[{0, 0}] = "出生点";
    gridNames[{1, 2}] = "宝箱";
    gridNames[{3, 4}] = "怪物";

    GridPos target{1, 2};

    if (gridNames.find(target) != gridNames.end()) {
        cout << gridNames[target] << endl;
    }

    return 0;
}

这个写法面试最稳。

方式 2:自定义相等函数

如果你不想在结构体里写 operator==,也可以单独写相等比较器:

c
#include <iostream>
#include <unordered_set>
using namespace std;

struct Point {
    int x;
    int y;
};

struct PointHash {
    size_t operator()(const Point& p) const {
        return hash<int>()(p.x) ^ (hash<int>()(p.y) << 1);
    }
};

struct PointEqual {
    bool operator()(const Point& a, const Point& b) const {
        // x 和 y 都相等,才认为是同一个点
        return a.x == b.x && a.y == b.y;
    }
};

int main() {
    unordered_set<Point, PointHash, PointEqual> s;

    s.insert({10, 20});
    s.insert({10, 20});

    cout << s.size() << endl; // 输出 1

    return 0;
}

模板参数含义是:

c
unordered_set<元素类型, 哈希函数, 相等判断>

也就是:

c
unordered_set<Point, PointHash, PointEqual>

方式 3:特化 std::hash

你也可以让自己的类型像 intstring 一样直接被 unordered_set 使用。

c
#include <iostream>
#include <unordered_set>
using namespace std;

struct Point {
    int x;
    int y;

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

namespace std {
    template <>
    struct hash<Point> {
        size_t operator()(const Point& p) const {
            size_t h1 = hash<int>()(p.x);
            size_t h2 = hash<int>()(p.y);

            return h1 ^ (h2 << 1);
        }
    };
}

int main() {
    unordered_set<Point> s;

    s.insert({1, 2});
    s.insert({1, 2});
    s.insert({3, 4});

    cout << s.size() << endl; // 输出 2

    return 0;
}

这样就可以直接写:

c
unordered_set<Point> s;

不过注意:通常只建议对你自己定义的类型特化 std::hash,不要随便改标准库已有类型。

方式 4:用 lambda 写哈希函数

临时用一下时,可以用 lambda:

c
#include <iostream>
#include <unordered_set>
using namespace std;

struct Point {
    int x;
    int y;

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

int main() {
    auto hashPoint = [](const Point& p) {
        size_t h1 = hash<int>()(p.x);
        size_t h2 = hash<int>()(p.y);
        return h1 ^ (h2 << 1);
    };

    unordered_set<Point, decltype(hashPoint)> s(10, hashPoint);

    s.insert({1, 2});
    s.insert({3, 4});

    cout << s.size() << endl;

    return 0;
}

这里比较绕的是:

c
decltype(hashPoint)

表示“这个 lambda 的类型”。

构造时也要把 lambda 对象传进去:

c
unordered_set<Point, decltype(hashPoint)> s(10, hashPoint);

pair 怎么自定义哈希?

这个也很常考,比如想用坐标作为 key:

c
#include <iostream>
#include <unordered_map>
using namespace std;

struct PairHash {
    size_t operator()(const pair<int, int>& p) const {
        size_t h1 = hash<int>()(p.first);
        size_t h2 = hash<int>()(p.second);

        // 混合 first 和 second
        return h1 ^ (h2 << 1);
    }
};

int main() {
    unordered_map<pair<int, int>, string, PairHash> grid;

    grid[{0, 0}] = "出生点";
    grid[{2, 3}] = "宝箱";

    cout << grid[{2, 3}] << endl;

    return 0;
}

不要写很差的哈希函数

比如:

c
struct BadHash {
    size_t operator()(const Point& p) const {
        return 1; // 所有 key 都进同一个桶
    }
};

这样虽然程序可能还能运行,但哈希表会退化,查找效率可能从平均:

c
O(1)

退化成:

c
O(n)

因为所有元素都挤在同一个桶里。

更好的 hash combine 写法

简单面试可以写:

c
return h1 ^ (h2 << 1);

如果想更专业一点,可以写类似:

c
size_t seed = 0;

seed ^= hash<int>()(p.x) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
seed ^= hash<int>()(p.y) + 0x9e3779b9 + (seed << 6) + (seed >> 2);

return seed;

完整例子:

c
struct PointHash {
    size_t operator()(const Point& p) const {
        size_t seed = 0;

        // 把 x 的哈希混入 seed
        seed ^= hash<int>()(p.x) + 0x9e3779b9 + (seed << 6) + (seed >> 2);

        // 把 y 的哈希混入 seed
        seed ^= hash<int>()(p.y) + 0x9e3779b9 + (seed << 6) + (seed >> 2);

        return seed;
    }
};

你不用死记这个常数,只要面试能说出“多个字段的哈希要组合,尽量减少冲突”就很好。

TIP

面试高分回答: 在 C++ 里,如果要让自定义类型作为 unordered_mapunordered_set 的 key,需要提供哈希函数和相等判断。哈希函数通常写成函数对象,重载 operator(),返回 size_t,内部可以使用 std::hash<int>std::hash<string> 分别计算字段哈希,再组合起来。同时必须保证相等规则和哈希规则一致:如果两个 key 相等,它们的 hash 值必须相同;但 hash 值相同不一定表示 key 相等,因为可能有哈希冲突,最终还要靠 operator== 或自定义 KeyEqual 判断。实际项目中要避免所有 key 都落到同一个桶里,否则 unordered_map 平均 O(1) 的查找可能退化成 O(n)

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0