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

四、STL 容器与数据结构(第 10~17 项补全)

一句话总览:红黑树和哈希表分别支撑有序关联容器与无序关联容器;二叉树是很多搜索结构的基础;LRU 是"哈希表 + 双向链表"的经典组合;容器选择的本质是根据访问、增删、有序性、内存和稳定性需求做权衡;迭代器则把算法和容器解耦。

10~17 知识点关系

复制代码
基础数据结构
├─ 12. 二叉树
│   ├─ 二叉搜索树 BST
│   ├─ 平衡树
│   │  └─ 10. 红黑树 ──> map/set
│   └─ 堆 ──> priority_queue
│
├─ 11. Hash
│   └─ 哈希表 ──> unordered_map/unordered_set
│
├─ 14. 数组 vs 链表
│   ├─ 数组 ──> vector / deque
│   └─ 链表 ──> list / forward_list
│
├─ 13. LRU
│   └─ 哈希表 + 双向链表
│
├─ 16. 迭代器
│   └─ 连接容器与泛型算法
│
├─ 17. vector 底层与扩容
│
└─ 15. 容器选择原则
    └─ 根据访问模式、增删位置、有序性、查找方式和内存特征选择

10. 介绍一下红黑树

核心:红黑树是一种自平衡二叉搜索树,它通过"节点颜色 + 旋转 + 重新染色"保证树高约为 O(log n),从而使查找、插入、删除都保持 O(log n)。C++ 中的 map/set/multimap/multiset 主流实现通常使用红黑树。

红黑树首先是一棵二叉搜索树:

复制代码
左子树 key < 根节点 key < 右子树 key

普通二叉搜索树如果按有序数据插入,可能退化成链表:

复制代码
1
 \
  2
   \
    3
     \
      4

红黑树通过规则避免这种极端退化。

红黑树五条性质

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

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

复制代码
             30(B)
            /     \
         10(B)    50(B)
            \      /
          20(R) 40(R)

基本操作

查找过程与二叉搜索树相同:

复制代码
find(40)
40 > 30,进入右子树
40 < 50,进入左子树
找到 40

插入新节点时,通常先把新节点染成红色。这样不会增加路径上的黑色节点数量,但可能违反"不能连续红色"的规则,因此需要修复。修复手段主要有两种:

  • 变色:红黑节点颜色互换。
  • 旋转:左旋或右旋,调整树结构。

左旋示意:

复制代码
    A                B
     \              /
      B     ->     A
     /              \
    X                X

简化代码:

复制代码
enum class Color { RED, BLACK };

struct Node {
    int key;
    Color color;
    Node *left, *right, *parent;
};

Node* rotate_left(Node* root) {
    Node* new_root = root->right;
    root->right = new_root->left;

    if (new_root->left) {
        new_root->left->parent = root;
    }

    new_root->left = root;
    new_root->parent = root->parent;
    root->parent = new_root;

    return new_root;
}

实际插入修复还要根据叔叔节点颜色分情况处理:

复制代码
插入后违反红黑性质
├─ 叔叔是红色:父、叔、祖父重新染色
└─ 叔叔是黑色:根据 LL/LR/RR/RL 情况旋转并染色

红黑树与 AVL 树

对比 红黑树 AVL 树
平衡严格程度 近似平衡 严格平衡
高度 略高 更低
查找 O(log n) O(log n),可能略快
插入删除 旋转较少 可能频繁旋转
典型用途 map/set 查找极多、修改较少场景

红黑树并不追求绝对平衡,而是用较弱的平衡条件减少插入删除时的调整次数,因此综合性能稳定,适合通用关联容器。

复制代码
#include <iostream>
#include <map>

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

    tree[30] = "thirty";
    tree[10] = "ten";
    tree[20] = "twenty";

    // 中序遍历:10 20 30
    for (const auto& [key, value] : tree) {
        std::cout << key << ' ' << value << '\n';
    }
}

面试中要注意表述严谨:C++ 标准并没有规定 map 必须用红黑树,但主流标准库通常使用红黑树来满足有序性和 O(log n) 复杂度要求。


11. 介绍一下 hash

核心:Hash,即哈希或散列,是把任意长度输入通过哈希函数映射成固定长度值的过程。哈希表利用哈希值快速定位元素,平均查找、插入、删除可达 O(1);但哈希冲突、负载因子和 rehash 会影响实际性能。

哈希过程如下:

复制代码
key: "Alice"
   ↓ hash("Alice")
固定长度整数,例如 1298371
   ↓ bucket_index = hash % bucket_count
桶下标
   ↓
在对应桶中查找或插入

一个好的非加密哈希函数通常具备以下特点:

  1. 确定性:相同输入一定得到相同输出。
  2. 计算快速:适合频繁查找。
  3. 分布均匀:尽量让 key 分散到不同桶。
  4. 雪崩效应:输入发生微小变化,输出应明显不同。
  5. 冲突概率低:但理论上无法完全避免冲突。

哈希冲突

由于输入空间远大于固定哈希值空间,不同 key 映射到同一桶是不可避免的,这叫哈希冲突。

复制代码
hash(18) % 4 = 2
hash(34) % 4 = 2

bucket 2: 18 -> 34

常见冲突处理方式:

  • 链地址法:每个桶挂一个链表或节点序列。
  • 开放寻址法:冲突后按规则寻找下一个空位置,如线性探测、二次探测。
  • 再哈希法:使用第二个哈希函数决定步长。

C++ 标准没有强制规定 unordered_map 的具体实现,但主流版本通常采用桶加链的方式。

复制代码
#include <iostream>
#include <string>
#include <unordered_map>

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

    score["Alice"] = 95;
    score["Bob"] = 88;

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

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

负载因子为:

复制代码
load_factor = 元素个数 / 桶个数

负载因子越高,冲突越多。当它超过最大负载因子时,哈希表会 rehash:申请更多桶,并把旧元素重新映射到新桶。

复制代码
rehash 前:
bucket0: A -> X
bucket1:
bucket2: B
bucket3:

扩容后重新分布:
bucket0:
bucket1: A
bucket2: X
bucket3: B
bucket4:
...

自定义类型作为 key 时,需要同时提供"哈希函数"和"相等判断":

复制代码
#include <cstddef>
#include <functional>
#include <unordered_map>

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);
    }
};

int main() {
    std::unordered_map<Point, int, PointHash> count;
    count[{1, 2}] = 10;
}

需要区分普通哈希和加密哈希。std::hash 主要用于哈希容器,追求快速和分布均匀,不用于密码安全;SHA-256 等加密哈希追求抗碰撞和单向性,但计算成本更高。哈希表平均 O(1),但如果哈希函数极差或遭遇冲突攻击,最坏可退化为 O(n),因此不能只记住"hash 一定最快"。


12. 介绍一下二叉树

核心:二叉树是每个节点最多有两个孩子的树结构,两个孩子分别称为左孩子和右孩子。二叉搜索树、堆、红黑树、AVL 树等都建立在二叉树基础上。

基本结构:

复制代码
        A
       / \
      B   C
     / \
    D   E

相关术语:

  • 根节点:最顶层节点,如 A。
  • 叶子节点:没有孩子的节点,如 C、D、E。
  • 高度:从当前节点到最远叶子的最长边数或节点数,具体定义需说明口径。
  • 深度:从根到该节点的路径长度。
  • 左子树、右子树:节点左右孩子构成的子树。

二叉树节点定义:

复制代码
struct TreeNode {
    int value;
    TreeNode* left;
    TreeNode* right;

    explicit TreeNode(int x)
        : value(x), left(nullptr), right(nullptr) {}
};

常见二叉树类型

类型 特点
普通二叉树 每个节点最多两个孩子
满二叉树 每个节点要么没有孩子,要么有两个孩子
完全二叉树 除最后一层外都填满,最后一层节点尽量靠左
完美二叉树 所有叶子都在同一层
二叉搜索树 左子树值小于根,右子树值大于根
平衡二叉树 左右高度差受控,如 AVL、红黑树
完全二叉树,同时满足大根堆或小根堆性质

二叉搜索树:

复制代码
        30
       /  \
      10   50
       \     \
       20     60

查找代码:

复制代码
TreeNode* search(TreeNode* root, int target) {
    while (root) {
        if (target == root->value) {
            return root;
        }

        root = target < root->value ? root->left : root->right;
    }

    return nullptr;
}

二叉搜索树平均高度为 O(log n),查找、插入、删除平均 O(log n)。但如果按有序序列插入,可能退化成链表,最坏 O(n)。红黑树和 AVL 树就是为了解决退化问题。

四种遍历

复制代码
        1
       / \
      2   3
     / \
    4   5
  • 前序遍历:根 → 左 → 右,结果 1 2 4 5 3
  • 中序遍历:左 → 根 → 右,结果 4 2 5 1 3
  • 后序遍历:左 → 右 → 根,结果 4 5 2 3 1
  • 层序遍历:按层访问,结果 1 2 3 4 5

递归遍历:

复制代码
#include <iostream>

void preorder(TreeNode* root) {
    if (!root) return;

    std::cout << root->value << ' ';
    preorder(root->left);
    preorder(root->right);
}

void inorder(TreeNode* root) {
    if (!root) return;

    inorder(root->left);
    std::cout << root->value << ' ';
    inorder(root->right);
}

void postorder(TreeNode* root) {
    if (!root) return;

    postorder(root->left);
    postorder(root->right);
    std::cout << root->value << ' ';
}

层序遍历使用队列:

复制代码
#include <queue>

void level_order(TreeNode* root) {
    if (!root) return;

    std::queue<TreeNode*> q;
    q.push(root);

    while (!q.empty()) {
        TreeNode* node = q.front();
        q.pop();

        std::cout << node->value << ' ';

        if (node->left) q.push(node->left);
        if (node->right) q.push(node->right);
    }
}

完全二叉树还可以用数组存储。若根节点下标为 i,则:

复制代码
左孩子下标:2i + 1
右孩子下标:2i + 2
父节点下标:(i - 1) / 2

这正是堆和 priority_queue 的基础。理解二叉树时,要把"结构形状"和"节点值规则"分开:红黑树强调平衡规则,BST 强调大小关系,堆强调父子大小关系和完全二叉树形状。


13. 编程实现 LRU

核心:LRU,Least Recently Used,最近最少使用缓存,淘汰最久没有访问的元素。要求 getput 都为 O(1),经典实现是"哈希表 + 双向链表":哈希表负责快速定位,双向链表负责维护访问时间顺序。

结构如下:

复制代码
最近使用                                         最久未使用
head ↔ [key=2] ↔ [key=5] ↔ [key=9] ↔ tail
       ↑ 访问或插入某 key 时移动到 head 前面
                     容量不足时删除 tail 前面的节点
  • 链表头部:最近使用。
  • 链表尾部:最久未使用。
  • 哈希表:key -> Node*,实现 O(1) 查找。
  • 双向链表:已知节点指针后,O(1) 删除和移动。

完整 C++ 实现:

复制代码
#include <iostream>
#include <unordered_map>

class LRUCache {
private:
    struct Node {
        int key;
        int value;
        Node* prev = nullptr;
        Node* next = nullptr;

        Node(int k, int v) : key(k), value(v) {}
    };

    int capacity;
    int size = 0;
    Node* head;
    Node* tail;
    std::unordered_map<int, Node*> table;

    // 从链表中摘下节点,但不删除内存
    void detach(Node* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

    // 插入到 head 后面,表示最近使用
    void add_to_front(Node* node) {
        node->prev = head;
        node->next = head->next;

        head->next->prev = node;
        head->next = node;
    }

    void move_to_front(Node* node) {
        detach(node);
        add_to_front(node);
    }

    void remove_lru() {
        Node* lru = tail->prev;
        detach(lru);
        table.erase(lru->key);
        delete lru;
        --size;
    }

public:
    explicit LRUCache(int cap) : capacity(cap) {
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head->next = tail;
        tail->prev = head;
    }

    ~LRUCache() {
        Node* current = head;
        while (current) {
            Node* next = current->next;
            delete current;
            current = next;
        }
    }

    int get(int key) {
        auto it = table.find(key);
        if (it == table.end()) {
            return -1;
        }

        Node* node = it->second;
        move_to_front(node);
        return node->value;
    }

    void put(int key, int value) {
        auto it = table.find(key);

        if (it != table.end()) {
            Node* node = it->second;
            node->value = value;
            move_to_front(node);
            return;
        }

        Node* node = new Node(key, value);
        table[key] = node;
        add_to_front(node);
        ++size;

        if (size > capacity) {
            remove_lru();
        }
    }
};

使用示例:

复制代码
int main() {
    LRUCache cache(2);

    cache.put(1, 100);
    cache.put(2, 200);

    std::cout << cache.get(1) << '\n'; // 100,key=1 变最近使用

    cache.put(3, 300); // 容量为 2,淘汰最久未使用的 key=2

    std::cout << cache.get(2) << '\n'; // -1
    std::cout << cache.get(3) << '\n'; // 300
}

为什么不能只用哈希表?因为哈希表能快速查找,但不能天然维护"最近访问顺序"。为什么不能只用链表?因为查找 key 需要 O(n)。二者结合后,哈希表 O(1) 找到节点,双向链表 O(1) 调整节点位置。

生产环境还需要考虑:多线程访问时加互斥锁;容量很大时关注内存分配;如果缓存策略按访问频率淘汰,应使用 LFU 而不是 LRU;也可以用 std::list + unordered_map<key, list::iterator> 减少手写指针错误。核心面试点是:访问过的元素移到头部,容量不足时删除尾部,哈希表和链表必须同步更新。


14. C/C++ 中数组和链表的优缺点

核心:数组使用连续内存,随机访问快、缓存友好,但中间插入删除慢;链表使用节点和指针,已知节点位置时插入删除快,但不支持随机访问、额外指针开销大、缓存性能差。

内存结构对比

数组:

复制代码
连续内存
地址:1000 1004 1008 1012
值:  [A ] [B ] [C ] [D ]

链表:

复制代码
节点可能分散在堆上
[A|*] ──> [B|*] ──> [C|nullptr]
   ↑           ↑
 0x12        0x88

数组访问第 i 个元素可以直接计算地址:

复制代码
address(i) = base + i × sizeof(element)

链表访问第 i 个元素必须从头沿指针走 i 次。

复制代码
#include <array>
#include <iostream>
#include <list>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    std::cout << arr[3] << '\n'; // O(1)

    std::list<int> lst = {1, 2, 3, 4, 5};
    auto it = lst.begin();
    std::advance(it, 3); // O(n)
    std::cout << *it << '\n';
}

对比表

维度 数组 链表
内存 连续 节点分散
随机访问 O(1) O(n)
按下标查找 O(1) O(n)
尾部插入 动态数组均摊 O(1) O(1)
头部插入 O(n),动态数组 双向/带头尾链表 O(1)
已知位置插入 O(n),需移动元素 O(1)
已知位置删除 O(n) O(1)
额外空间 预留容量可能浪费 每节点有指针开销
缓存局部性
内存分配 一次性或扩容时分配 插入节点时频繁分配
大小变化 静态数组固定,vector 可增长 动态增长自然

数组优点

第一,随机访问极快。第二,顺序遍历时缓存命中率高。第三,没有指针额外开销,存储小对象时空间利用率高。第四,适合二分查找、排序、向量化计算。

数组缺点是插入删除可能移动大量元素:

复制代码
在数组 [A][B][C][D] 的 B 前插入 X:

[A][ ][B][C][D]
     ↑ B、C、D 全部后移
[A][X][B][C][D]

静态数组大小编译期固定;C++ vector 虽然能动态扩容,但扩容时可能移动所有元素。

链表优点

第一,已知节点位置时插入删除只需修改指针:

复制代码
删除 B:
[A] ↔ [B] ↔ [C]

修改 A.next、C.prev:
[A] ↔ [C]

第二,节点按需分配,不需要预先占用大块连续空间。第三,插入不会移动其他元素,其他节点的迭代器和引用通常保持有效。

链表缺点是随机访问慢、指针占用空间、频繁 new/delete 开销大、缓存不友好。双向链表还要保存前驱和后继两个指针。

工程中不要机械地认为"插入删除多就一定用链表"。如果找到插入位置本身要 O(n),而数组移动元素在连续内存上非常快,链表未必占优。大多数 C++ 场景仍应优先 vector;只有在已经持有目标迭代器、需要稳定引用、频繁拼接或删除任意已知节点时,才考虑 list


15. 容器选择的原则

核心:容器选择不能只看理论复杂度,还要结合访问模式、增删位置、是否有序、key 类型、对象大小、内存局部性、迭代器稳定性和实时性要求。默认选择通常是 vector

选择流程图

复制代码
是否需要 key-value 或集合查找?
├─ 否:序列容器
│   ├─ 主要随机访问/顺序遍历 → vector
│   ├─ 头尾都频繁增删 → deque
│   ├─ 已知位置频繁任意插入删除/稳定引用 → list
│   ├─ 大小固定 → array
│   └─ LIFO/FIFO/优先级 → stack/queue/priority_queue
│
└─ 是:关联容器
    ├─ 需要有序遍历或范围查询 → map/set
    ├─ 只做等值查找,不关心顺序 → unordered_map/unordered_set
    └─ key 可重复 → multimap/multiset 或 unordered_multimap/unordered_multiset

原则一:默认使用 vector

vector 连续内存、随机访问 O(1)、缓存友好,并且与 C API 兼容。很多看似适合链表的场景,实际 benchmark 中仍是 vector 更快。

复制代码
std::vector<User> users;
users.reserve(expected_size);

原则二:根据主要操作选择

主要操作 更适合的容器
大量顺序遍历 vector
频繁随机访问 vector/array/deque
频繁尾部追加 vector
频繁头尾增删 deque
已知位置频繁插入删除 list
有序 key 查找 map/set
精确 key 平均 O(1) 查找 unordered_map/unordered_set
每次取最大/最小值 priority_queue
先进先出 queue
后进先出 stack

原则三:看是否需要有序性

如果需要范围查询:

复制代码
std::map<long long, Order> orders;

auto begin = orders.lower_bound(start_time);
auto end = orders.upper_bound(end_time);

for (auto it = begin; it != end; ++it) {
    // 时间范围内的订单
}

此时 mapunordered_map 合适。若只根据 ID 查对象:

复制代码
std::unordered_map<int64_t, User> user_by_id;

哈希表通常更合适。

原则四:关注对象大小和拷贝成本

如果对象很大,vector 扩容时移动成本可能较高。可以:

  • 使用 reserve 避免多次扩容;
  • 存储 unique_ptr<T> 或间接对象;
  • 使用 emplace_back 原地构造;
  • 避免不必要的拷贝。

但不要默认"对象大就用链表",还要看是否真的有任意位置增删需求。

原则五:关注迭代器和引用稳定性

复制代码
std::vector<int> v = {1, 2, 3};
int* p = &v[0];
v.reserve(1000); // 若触发扩容,p 可能失效

vector 扩容会使迭代器、指针、引用失效;list 插入不会使其他迭代器失效;map/set 插入通常也不会使已有迭代器失效。如果算法需要长期保存元素位置,这一点很重要。

原则六:关注性能确定性

unordered_map 平均 O(1),但 rehash 和哈希冲突可能带来延迟尖峰;map 每次 O(log n),性能更可预测。实时系统、嵌入式系统有时更重视最坏情况,而不是平均速度。

原则七:不要忽视线程安全

STL 容器本身通常不负责多线程同步。多个线程同时修改容器,或一个线程修改而另一个线程遍历,都需要外部加锁。选择容器不能替代并发设计。

最终可以用一句话做决策:没有特殊理由就用 vector;有明确双端需求用 deque;有明确已知节点增删和稳定引用需求用 list;要有序用红黑树容器;要平均最快等值查找用哈希容器;要特殊调度规则用适配器。


16. 什么是迭代器,有哪几种迭代器

核心:迭代器是连接容器和算法的抽象层,它像一个"智能指针",可以用统一方式遍历不同容器,而算法不需要知道容器底层是数组、链表还是树。STL 算法通常通过迭代器操作元素。

例如 std::find 不关心容器类型:

复制代码
#include <algorithm>
#include <iostream>
#include <list>
#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};
    std::list<int> l = {1, 2, 3};

    auto it1 = std::find(v.begin(), v.end(), 2);
    auto it2 = std::find(l.begin(), l.end(), 2);

    std::cout << *it1 << ' ' << *it2 << '\n';
}

find 只要求迭代器支持递增和解引用,因此同一算法可以作用于多种容器。

迭代器基本分类

C++ 传统迭代器按能力从弱到强分为五类,C++20 又明确提出连续迭代器概念。

类别 能力 典型例子
输入迭代器 单向、只读、单趟遍历 istream_iterator
输出迭代器 单向、只写、单趟遍历 ostream_iterator、插入迭代器
前向迭代器 可单向多趟读写 forward_listunordered_map
双向迭代器 可向前和向后移动 listmapset
随机访问迭代器 可跳跃、比较距离 vectordeque、数组指针
连续迭代器 随机访问且逻辑元素物理连续 vectorarray,C++20

能力关系:

复制代码
输入/输出
   ↓
前向
   ↓
双向
   ↓
随机访问
   ↓
连续(C++20,强调内存连续)

输入迭代器

只能逐个读取,通常不保证回头后还能重新读取同一序列:

复制代码
#include <iterator>
#include <iostream>

int main() {
    std::istream_iterator<int> input(std::cin);
    std::istream_iterator<int> end;

    if (input != end) {
        std::cout << *input << '\n';
    }
}

输出迭代器

用于写出元素:

复制代码
#include <iterator>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    auto out = std::back_inserter(v);

    *out = 1;
    ++out;
    *out = 2;
}

前向迭代器

可以反复沿一个方向遍历,但不能后退。std::forward_list 是典型例子:

复制代码
std::forward_list<int> fl = {1, 2, 3};
for (auto it = fl.begin(); it != fl.end(); ++it) {
    // 只能 ++,不能 --
}

双向迭代器

支持 ++--。链表和红黑树容器属于这一类:

复制代码
std::map<int, int> mp = {{1, 10}, {2, 20}};
auto it = mp.begin();
++it;
--it; // 可以

随机访问迭代器

支持 it + nit - nit[n] 和大小比较:

复制代码
std::vector<int> v = {10, 20, 30};
auto it = v.begin();

std::cout << it[2] << '\n';
std::cout << *(it + 1) << '\n';
std::cout << (it < v.end()) << '\n';

list 迭代器不能 it + 5,只能多次 ++,因为链表地址不连续,跳跃访问必须线性移动。

迭代器失效

容器修改后,旧迭代器可能不再有效:

复制代码
std::vector<int> v = {1, 2, 3};
auto it = v.begin();

v.reserve(1000); // 如果发生扩容,it 失效

不同容器失效规则不同:

  • vector:扩容后全部失效;插入点之后通常失效。
  • deque:迭代器失效规则复杂,中间插入删除影响更大。
  • list:只有被删除元素的迭代器失效。
  • map/set:只有被删除元素的迭代器失效。
  • unordered_map:rehash 会使迭代器失效,但元素引用通常仍有效。

理解迭代器分类有助于理解算法要求。例如 std::sort 需要随机访问迭代器,因此不能直接排序 std::listlist 提供自己的 sort() 成员函数。std::reverse 需要双向迭代器,因此可用于 list/map 遍历方向,但不能用于 forward_list。迭代器的本质价值是:让算法只依赖遍历能力,而不依赖具体容器结构。


17. vector 的底层原理和扩容机制是什么?

核心:vector 底层是一块连续动态内存,内部通常用三个指针管理:起始指针、已用元素末尾指针、容量末尾指针。当容量不足时,它会申请更大的连续空间,把旧元素移动或拷贝过去,再释放旧空间;由于采用几何倍数扩容,多次 push_back 的均摊复杂度是 O(1)。

内部结构可抽象为:

复制代码
template <class T>
class Vector {
private:
    T* first = nullptr;       // 起始位置
    T* last = nullptr;        // 已用元素末尾
    T* capacity_end = nullptr;// 容量末尾
};

图示:

复制代码
first         last          capacity_end
↓             ↓             ↓
[A][B][C][D][ ][ ][ ][ ][ ][ ]

size     = last - first = 4
capacity = capacity_end - first = 10

push_back 过程

复制代码
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);

size < capacity 时,直接在尾部构造新元素。当 size == capacity 时:

复制代码
1. 申请一块更大的新内存
2. 把旧元素移动或拷贝到新内存
3. 析构旧内存中的对象
4. 释放旧内存
5. 在新内存尾部构造新元素

简化实现:

复制代码
#include <algorithm>
#include <cstddef>

template <class T>
class SimpleVector {
private:
    T* data = nullptr;
    std::size_t sz = 0;
    std::size_t cap = 0;

public:
    void push_back(const T& value) {
        if (sz == cap) {
            std::size_t new_cap = cap == 0 ? 1 : cap * 2;
            T* new_data = static_cast<T*>(::operator new(new_cap * sizeof(T)));

            for (std::size_t i = 0; i < sz; ++i) {
                new (&new_data[i]) T(std::move_if_noexcept(data[i]));
            }

            for (std::size_t i = 0; i < sz; ++i) {
                data[i].~T();
            }

            ::operator delete(data);
            data = new_data;
            cap = new_cap;
        }

        new (&data[sz]) T(value);
        ++sz;
    }

    std::size_t size() const { return sz; }
    std::size_t capacity() const { return cap; }

    T& operator[](std::size_t i) { return data[i]; }
};

真实 STL 实现会使用分配器 allocator,通过 placement new 在已申请但未初始化的内存上构造对象,而不是简单使用 new T[]

为什么均摊是 O(1)

假设容量按 2 倍增长:

复制代码
容量变化:1 → 2 → 4 → 8 → 16
插入 n 个元素时,扩容搬移总量约为:
1 + 2 + 4 + ... + n/2 ≈ n

虽然某一次扩容可能复制 n 个元素,成本 O(n),但分摊到 n 次插入上,平均每次仍是 O(1),这叫均摊常数复杂度。

扩容倍数由标准库实现决定,并不保证一定是 2 倍。MSVC、GCC、Clang 的策略可能不同,常见为 2 倍或 1.5 倍。

reserve、resize、shrink_to_fit

复制代码
#include <vector>

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

    v.reserve(1000); // 只改变 capacity,不增加 size
    // v.size() 仍为 0

    v.resize(10);    // 改变 size,多出来的元素被值初始化
    // v.size() 为 10

    v.shrink_to_fit(); // 请求释放多余容量,但不保证一定生效
}

区别如下:

操作 作用
reserve(n) 保证容量至少为 n,减少扩容
resize(n) 改变元素个数,可能构造或销毁元素
clear() 销毁所有元素,size 变 0,但容量通常不变
shrink_to_fit() 请求归还多余容量
emplace_back() 在尾部原地构造,减少临时对象

迭代器失效

扩容后旧内存被释放,因此指向旧内存的迭代器、指针和引用都会失效:

复制代码
std::vector<int> v = {1, 2, 3};
auto it = v.begin() + 1;

v.reserve(1000);

// 若发生扩容,it 已失效,不能继续使用

即使没有扩容,插入位置及之后的迭代器也可能因元素移动而失效;删除点之后的迭代器同样不能按原语义继续使用。

重要优化建议

如果提前知道元素数量,应使用 reserve

复制代码
std::vector<int> v;
v.reserve(100000);

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

这样可以避免多次扩容、对象搬移和内存碎片。另一个细节是:C++11 后 vector 扩容时会优先使用移动构造,但如果移动构造可能抛异常,为了提供强异常安全保证,编译器可能退化为拷贝构造,因此大对象的移动构造最好声明为 noexcept

总结:vector 是"连续数组 + 几何扩容 + 自动对象生命周期管理"的组合,随机访问 O(1),尾部追加均摊 O(1),中间插入删除 O(n),扩容会导致迭代器整体失效。


17 项整体总结

知识点 一句话记忆
vector 连续动态数组,随机访问快,尾部追加均摊 O(1)
list 双向链表,已知位置增删 O(1),随机访问慢
deque 分段连续,双端增删 O(1),支持随机访问
map/set 红黑树,有序,O(log n)
mutable const 对象中允许修改缓存、锁等逻辑辅助成员
map 底层 主流实现为红黑树
unordered_map 哈希表,平均 O(1),无序
hashmap vs map 哈希平均快,红黑树有序且最坏复杂度稳定
红黑树 自平衡 BST,靠颜色和旋转维持 O(log n)
hash 将输入映射为固定值,哈希表靠它定位桶
二叉树 最多两个孩子,是 BST、堆、红黑树的基础
LRU 哈希表 + 双向链表,get/put O(1)
数组 vs 链表 数组连续、访问快;链表节点分散、已知位置增删快
容器选择 默认 vector,再按访问和增删特征替换
迭代器 算法与容器之间的统一遍历抽象
vector 扩容 几何倍数申请新空间并搬移元素,均摊 O(1)

最核心的选型主线是:

复制代码
连续内存优先 vector
双端操作选 deque
已知节点增删选 list
有序和范围查询选 map/set
等值快速查找选 unordered_map/unordered_set
缓存淘汰用 hash + 双向链表
算法统一通过 iterator 操作容器
相关推荐
会周易的程序员1 小时前
5Draft使用说明书
服务器·c++·分布式·raft·共识
ysu_03141 小时前
02-单链表完全指南
c语言·数据结构·算法·leetcode
码匠许师傅1 小时前
【设计模式精讲】21.迭代器模式(Iterator)
c++·设计模式·rpc·迭代器模式·软件工程·uml
Lyyaoo.1 小时前
【链表】【中等】两数相加/倒N删除/两个交换/排序链表/LRU缓存
数据结构·链表·缓存
YYYing.1 小时前
【C++进阶系列 (一)】关于线程堆栈的那些事 (上篇)
c语言·开发语言·c++·线程堆栈
bro_Java6661 小时前
链表进阶:链表笔试真题
数据结构·链表
努力努力再努力wz2 小时前
【Redis入门系列】:从 RESP 协议到 redis-plus-plus:Redis 客户端编程与 C++ 接口设计
开发语言·数据库·c++·redis·分布式·缓存·架构
知兀2 小时前
Tailwindcss报错:没有与此调用匹配的重载
前端·c++·tailwindcss
LB21122 小时前
力扣102 198 70 55
数据结构·算法·leetcode