vector【由浅入深-C++】

文章目录

  • 前言
  • 第一章:[std::vector](https://cplusplus.com/reference/vector/)初探与底层机制
    • [1. 为什么它是"默认首选"?](#1. 为什么它是“默认首选”?)
    • [2. 什么是 std::vector?](#2. 什么是 std::vector?)
    • [3. 骨架:vector 的底层内存模型](#3. 骨架:vector 的底层内存模型)
    • [4. 核心机制:Size vs Capacity](#4. 核心机制:Size vs Capacity)
    • [5. 扩容机制](#5. 扩容机制)
    • [6. 代码实战:观察 vector 的行为](#6. 代码实战:观察 vector 的行为)
    • [7. 总结:为什么选 vector?](#7. 总结:为什么选 vector?)
    • 补充:迭代器失效
    • 补充:vector和string区别?vector能替代string吗
      • [一、 核心区别对比表](#一、 核心区别对比表)
      • [二、 深入剖析三大差异](#二、 深入剖析三大差异)
        • [1. "\0" 结尾符 (Null Terminator) ------ 最大的坑](#1. “\0” 结尾符 (Null Terminator) —— 最大的坑)
        • [2. SSO (Small String Optimization) ------ 性能杀手锏](#2. SSO (Small String Optimization) —— 性能杀手锏)
        • [3. 接口能力的降维打击](#3. 接口能力的降维打击)
      • [三、 什么时候该用 vector 替代 string?](#三、 什么时候该用 vector 替代 string?)
      • 总结
    • 补充:={}初始化会隐式转换?先生成临时变量在拷贝复制?
      • [1. 写法一:不带等号 `T a{1};`](#1. 写法一:不带等号 T a{1};)
      • [2. 写法二:带等号 `T a = {1};`](#2. 写法二:带等号 T a = {1};)
        • [A. 隐式转换?(语义层面)](#A. 隐式转换?(语义层面))
        • [B. 先生成临时变量再拷贝?(物理层面)](#B. 先生成临时变量再拷贝?(物理层面))
      • [3. 特殊情况:`std::vector` 和 `std::initializer_list`](#3. 特殊情况:std::vectorstd::initializer_list)
      • 总结图解
  • 第二章:vector的模拟实现
    • 补充:内存分配与对象构造的分离
      • [问题一:为什么不能直接 `new T[N]`?](#问题一:为什么不能直接 new T[N]?)
        • [1. 核心矛盾:空间(Capacity)与 存在(Size)的分离](#1. 核心矛盾:空间(Capacity)与 存在(Size)的分离)
        • [2. 解决方案:内存分配与对象构造分离](#2. 解决方案:内存分配与对象构造分离)
        • [3. 什么是 Placement New?](#3. 什么是 Placement New?)
      • [问题二:POD 类型与非 POD 类型的处理策略 (类型萃取优化)](#问题二:POD 类型与非 POD 类型的处理策略 (类型萃取优化))
        • [1. 什么是 POD 类型?](#1. 什么是 POD 类型?)
        • [2. 为什么要区分它们?](#2. 为什么要区分它们?)
        • [3. 类型萃取 (Type Traits) 是如何实现的?](#3. 类型萃取 (Type Traits) 是如何实现的?)
      • 总结
    • 补充:[std::initializer_list](https://cplusplus.com/reference/initializer_list/initializer_list/)
      • [1. 什么是 `std::initializer_list`?](#1. 什么是 std::initializer_list?)
      • [2. 底层原理:它到底长什么样?](#2. 底层原理:它到底长什么样?)
      • [3. 如何在你的类中支持它?](#3. 如何在你的类中支持它?)
      • [4. 这里的坑:`()` vs `{}` 的优先级战争](#4. 这里的坑:() vs {} 的优先级战争)
      • [5. 致命陷阱:千万不要返回 `initializer_list`](#5. 致命陷阱:千万不要返回 initializer_list)
      • [6. 总结](#6. 总结)
    • 补充:指向同一块内存区域的不同指针是可以进行大小比较的
      • [1. 生活类比:门牌号](#1. 生活类比:门牌号)
      • [2. 内存图解](#2. 内存图解)
      • [3. 唯一的限制(重要!)](#3. 唯一的限制(重要!))
        • [✅ 合法的情况:](#✅ 合法的情况:)
        • [❌ 不合法(未定义行为)的情况:](#❌ 不合法(未定义行为)的情况:)
      • 总结
    • [补充:左闭右开区间[begin, end)](#补充:左闭右开区间[begin, end))
      • [1. 核心图解](#1. 核心图解)
      • [2. 为什么要这样设计?(设计的哲学)](#2. 为什么要这样设计?(设计的哲学))
        • [A. 极其优雅的"判空"逻辑](#A. 极其优雅的“判空”逻辑)
        • [B. 完美的循环写法](#B. 完美的循环写法)
        • [C. 计算元素个数](#C. 计算元素个数)
      • [3. 需要警惕的"陷阱"](#3. 需要警惕的“陷阱”)
        • [A. 容器适配器](#A. 容器适配器)
        • [B. 反向迭代器](#B. 反向迭代器)
      • 总结
    • 第一部分:核心实现 (`vector.h`)
    • 第二部分:测试用例 (`main.cpp`)
  • [第三章:深入理解 vector 实现二维数组 ------ `vector<vector<T>>`](#第三章:深入理解 vector 实现二维数组 —— vector<vector<T>>)
    • [1. 什么是 `vector<vector<T>>`?](#1. 什么是 vector<vector<T>>?)
    • [2. 核心差异:内存模型](#2. 核心差异:内存模型)
      • [(1) 原生二维数组 (`int arr[3][4]`)](#(1) 原生二维数组 (int arr[3][4]))
      • [(2) vector 的二维数组 (`vector<vector<int>>`)](#(2) vector 的二维数组 (vector<vector<int>>))
    • [3. 初始化与大小控制](#3. 初始化与大小控制)
      • [(1) 构造时指定大小(推荐)](#(1) 构造时指定大小(推荐))
      • [(2) 使用初始化列表(C++11)](#(2) 使用初始化列表(C++11))
      • [(3) 动态调整大小 (`resize`)](#(3) 动态调整大小 (resize))
    • [4. 特性:锯齿状数组](#4. 特性:锯齿状数组)
    • [5. 遍历与访问](#5. 遍历与访问)
      • [(1) 下标法(最像 C 语言)](#(1) 下标法(最像 C 语言))
      • [(2) 范围 for 循环(C++11,推荐)](#(2) 范围 for 循环(C++11,推荐))
    • [6. 高阶思考:性能陷阱与优化](#6. 高阶思考:性能陷阱与优化)
      • [优化方案:一维 vector 模拟二维](#优化方案:一维 vector 模拟二维)
    • [7. 总结](#7. 总结)
  • 补充:sore
    • 第一部分:基础用法
      • [1. 函数原型](#1. 函数原型)
      • [2. 代码实战](#2. 代码实战)
    • [第二部分:底层原理 ------ 内省排序](#第二部分:底层原理 —— 内省排序)
      • [1. 为什么要混合?](#1. 为什么要混合?)
      • [2. Introsort 的工作流程](#2. Introsort 的工作流程)
      • 图解算法流程
    • [第三部分:为什么它比 C 语言的 `qsort` 快?](#第三部分:为什么它比 C 语言的 qsort 快?)
      • [1. 内联 (Inlining) vs 函数指针](#1. 内联 (Inlining) vs 函数指针)
      • [2. 类型安全 vs `void*`](#2. 类型安全 vs void*)
    • [第四部分:致命陷阱 ------ 严格弱序 (Strict Weak Ordering)](#第四部分:致命陷阱 —— 严格弱序 (Strict Weak Ordering))
      • [❌ 经典错误案例](#❌ 经典错误案例)
      • [🔥 为什么会崩?](#🔥 为什么会崩?)
      • [✅ 正确法则](#✅ 正确法则)
    • [第五部分:std::sort vs std::stable_sort](#第五部分:std::sort vs std::stable_sort)
      • [1. 什么是稳定性?](#1. 什么是稳定性?)
      • [2. 什么时候用 `std::stable_sort`?](#2. 什么时候用 std::stable_sort?)
      • 图解稳定性
    • [第六部分:`std::less` 和 `std::greater`](#第六部分:std::lessstd::greater)
      • [1. 一张表看懂区别](#1. 一张表看懂区别)
      • [2. 底层长什么样?(源码级理解)](#2. 底层长什么样?(源码级理解))
      • [3. 实战用法](#3. 实战用法)
        • [场景 A:在 sort 中切换升降序](#场景 A:在 sort 中切换升降序)
        • [场景 B:自定义类型的使用](#场景 B:自定义类型的使用)
      • [4. 两个著名的"坑"](#4. 两个著名的“坑”)
        • [坑一:priority_queue 的反直觉](#坑一:priority_queue 的反直觉)
        • [坑二:C++14 的透明比较器 (Transparent Comparator)](#坑二:C++14 的透明比较器 (Transparent Comparator))
      • 总结
    • 第七部分:总结速查

前言

本文介绍vector的相关内容

(【由浅入深】是一个系列文章,它记录了我个人作为一个小白,在学习c++技术开发方向计相关知识过程中的笔记,欢迎各位彭于晏刘亦菲从中指出我的错误并且与我共同学习进步,作为该系列的第三部曲-c++,大部分知识框架会根据本人所学编写,由我的助手------Gimini,通义合并网络上所找到的相关资料进行核实优化,每一篇文章都可能会因为一些错误在后续时间增删改查,因为该系列会按照我在互联网中的学习笔记形式编写,我会使用绝大多数人使用的讲解顺序编写,所以基础框架和部分案例会与他人一样,基础知识不会过于详细讲述)


第一章:std::vector初探与底层机制

vector是类模板,使用需要显示指定类型

1. 为什么它是"默认首选"?

在 C++ 的 STL(标准模板库)中,如果让你只掌握一个容器,那绝对是 std::vector。连 C++ 之父 Bjarne Stroustrup 都建议:"在不确定使用什么容器时,请默认使用 vector。"

它不仅仅是一个数组,它是 C++ 动态内存管理哲学的集大成者。本章我们将揭开它"自动扩容"的面纱,看看它骨子里到底长什么样。

2. 什么是 std::vector?

一句话概括:std::vector 是一个能够自动管理内存的动态数组。

它具有以下核心特征:

  1. 连续内存:它的元素在物理内存中是连续存放的(和 C 数组一样)。这意味着它对 CPU 缓存(Cache)极其友好,访问速度极快。
  2. 动态增长 :你不需要像定义 int arr[10] 那样把长度写死。vector 会根据需要自动申请更大的内存。
  3. 随机访问 :支持 时间复杂度的下标访问(即 v[5])。

3. 骨架:vector 的底层内存模型

std::vector 对象本身(即栈上的那个变量)非常小,通常只包含 3 个指针(在 64 位系统下通常占 24 字节)。

它并不直接存储数据,而是指挥堆(Heap)上的内存。这 3 个指针分别是:

  1. _start (或 _Myfirst) :指向已分配内存的起始位置
  2. _finish (或 _Mylast) :指向有效数据的下一个位置(即最后一个元素的后面)。
  3. _end_of_storage (或 _Myend) :指向已分配容量的末尾。

图解结构

假设我们有一个容量为 6,大小为 4 的 vector:

text 复制代码
Stack (栈上对象)        Heap (堆内存)
+-----------------+    +---+---+---+---+---+---+
| _start       ---|--->| 1 | 2 | 3 | 4 | ? | ? |
+-----------------+    +---+---+---+---+---+---+
| _finish      ---|--------------------^       ^
+-----------------+                            |
| _end_of_storage |----------------------------+
+-----------------+

通过这三个指针,vector 可以瞬间算出两个核心概念:

  • Size (大小) = _finish - _start (当前存了多少个元素)
  • Capacity (容量) = _end_of_storage - _start (当前最多能存多少个元素,不需要重新分配内存)

4. 核心机制:Size vs Capacity

这是初学者最容易混淆的地方。

  • size():是你实际放入元素的个数。
  • capacity() :是容器为了避免频繁 malloc 而预先申请的空间。

生活类比

  • Capacity 是你买的书包的总容积(能装 10 本书)。
  • Size 是你现在书包里实际装的书(装了 3 本)。
  • 当你装第 11 本书时,因为书包满了,vector 会偷偷帮你换一个更大的新书包(扩容),把原来的书挪过去,再把旧书包扔掉。

5. 扩容机制

vector没有"自动缩容"机制。

当你调用 push_back 插入元素,且 size() == capacity() 时,就会触发扩容

步骤如下:

  1. 开辟新空间 :通常按原容量的 2倍 (GCC)或 1.5倍(MSVC)申请一块新的连续内存。
  2. 数据搬家:将旧内存中的数据拷贝(或移动)到新内存。
  3. 释放旧空间:销毁旧内存块。
  4. 更新指针 :将 _start 等指针指向新家。

注意 :扩容是一个高成本操作(涉及内存分配和数据拷贝)。如果你预先知道大概需要多少元素,请务必使用 reserve(n) 提前把坑占好,避免扩容带来的性能抖动。

6. 代码实战:观察 vector 的行为

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

int main() {
    // 1. 创建一个空的 vector
    std::vector<int> v;
    std::cout << "Initial: size=" << v.size() << ", capacity=" << v.capacity() << std::endl;

    // 2. 插入元素,观察扩容
    for (int i = 0; i < 10; ++i) {
        v.push_back(i);
        std::cout << "Push " << i << ": size=" << v.size() 
                  << ", capacity=" << v.capacity() << std::endl;
    }

    // 3. 随机访问
    std::cout << "Element at index 5: " << v[5] << std::endl;

    // 4. 遍历 (使用范围 for 循环)
    std::cout << "All elements: ";
    for (auto e : v) {
        std::cout << e << " ";
    }
    std::cout << std::endl;

    return 0;
}

VS2022 (MSVC) 下的输出预测(1.5倍增长):

text 复制代码
Push 0: size=1, capacity=1
Push 1: size=2, capacity=2
Push 2: size=3, capacity=3  (2*1.5 = 3)
Push 3: size=4, capacity=4  (3*1.5 = 4)
Push 4: size=5, capacity=6  (4*1.5 = 6)
...

7. 总结:为什么选 vector?

特性 描述
内存连续 支持随机访问 [],CPU 缓存命中率极高,遍历速度最快。
尾部插入 push_back 通常是 O ( 1 ) O(1) O(1) 的(均摊复杂度),非常快。
中间插入/删除 比较慢 ( O ( N ) O(N) O(N)),因为需要挪动后面的所有数据。
自动管理 RAII 机制,出了作用域自动析构,不仅释放数据,还释放堆内存。

一句话总结第一章
std::vector 是 C++ 程序员的瑞士军刀。它用 3 个指针封装了复杂的动态内存管理,让你既能享受数组的高效访问,又能拥有链表般的动态增长能力。

补充:迭代器失效

这是一个非常硬核且面试必问的话题。我们先给"迭代器失效"下一个精确的定义,然后深入到底层内存模型去分析原因,最后给出解决方案。

1. 什么是迭代器失效?

简单来说,迭代器失效 是指:你手里的迭代器(本质是个指针)所指向的内存地址已经不再属于原来的元素,或者该内存已经被释放了。

当你继续使用这个失效的迭代器(解引用 *it 或自增 ++it)时,程序会发生 未定义行为 (Undefined Behavior),通常表现为:

  • 程序崩溃 (Crash):访问了非法内存。
  • 数据错乱:读到了完全不相关的数据(脏数据)。
  • 什么都没发生:这是最可怕的,Bug 潜伏下来,以后随机爆发。

2. 为什么会失效?(底层原因)

对于 std::vector 这种连续内存容器 ,失效通常由两种操作引发:扩容 (Reallocation)移动 (Shifting)

场景一:扩容导致"整体失效" (The "Move House" Scenario)

这是最剧烈的失效情况。

  • 触发条件 :当你 push_backinsert 元素时,如果 size == capacity,vector 会触发自动扩容。
  • 底层动作
  1. new 一块更大的新内存。
  2. 把旧数据 copy/move 到新内存。
  3. delete 释放旧内存。
  • 结果 :你手里原本指向"旧内存"的所有迭代器、指针、引用,瞬间变成了悬空指针 (Dangling Pointers)

代码演示:

cpp 复制代码
vector<int> v = {1, 2, 3};
auto it = v.begin(); // it 指向 1

// 假设 capacity 也是 3,插入会导致扩容
v.push_back(4); 

// 此时 v 的数据已经搬到了新地址
// 但 it 还是指向旧地址(已经被 OS 回收了)
cout << *it << endl; // ❌ 崩溃或乱码!
场景二:插入/删除导致"局部失效" (The "Shift" Scenario)

即使没有发生扩容,数据的移动也会导致迭代器错位。

  • 插入 (Insert):插入点之后的所有元素都要向后挪动。指向这些"挪动元素"的迭代器虽然地址没变(如果没扩容),但逻辑上它们指向的内容变了(或者越界了)。
  • 删除 (Erase):删除点之后的所有元素都要向前挪动。
  • 被删除的那个迭代器:立即失效(指向的内存虽然还在,但意义上已经无效)。
  • 删除点之后的所有迭代器:全部失效,因为它们的相对位置变了。

3. 最经典的错误案例:遍历中删除

这是无数新手(甚至老手)在面试和工作中都会踩的坑。

❌ 错误写法:

cpp 复制代码
vector<int> v = {1, 2, 3, 4, 5};
// 任务:删除所有偶数
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0) {
        v.erase(it); // 💀 致命错误!
        // 原因:
        // 1. erase(it) 执行后,it 所指的元素被删除了,it 立即失效。
        // 2. 循环回到头部,执行 ++it。
        // 3. 对一个失效的迭代器进行 ++,行为未定义(直接炸裂)。
    }
}

✅ 正确写法:利用 erase 的返回值

C++ 标准规定,vector::erase(it)返回一个新迭代器 ,指向被删除元素的下一个位置

cpp 复制代码
for (auto it = v.begin(); it != v.end(); ) { // 注意:这里不写 ++it
    if (*it % 2 == 0) {
        it = v.erase(it); // ✅ 更新 it,指向删除元素的后一个
    } else {
        ++it; // ✅ 只有没删除时,才手动 ++
    }
}

4. 扩展:不同容器的失效规则对比

容器类型 插入操作导致的失效 删除操作导致的失效 原因简述
vector 如果发生扩容,所有迭代器、指针和引用失效;否则,仅插入点之后的迭代器、指针和引用失效 删除点及其后所有元素的迭代器、指针和引用失效 连续内存结构,插入或删除时可能需搬移数据
deque 在头或尾插入:迭代器可能失效,指针和引用不失效;在中间插入:所有迭代器、指针和引用失效 在头或尾删除:迭代器可能失效,指针和引用不失效;在中间删除:所有迭代器、指针和引用失效 复杂的 map+buffer 结构,分块存储数据
list 不失效任何现有迭代器、指针或引用 仅被删除元素的迭代器、指针或引用失效 节点式内存,每个元素独立分配,不涉及数据搬移
map 不失效任何现有迭代器、指针或引用 仅被删除元素的迭代器、指针或引用失效 节点式内存,每个元素独立分配,不涉及数据搬移
set 不失效任何现有迭代器、指针或引用 仅被删除元素的迭代器、指针或引用失效 节点式内存,每个元素独立分配,不涉及数据搬移

解释

  • 失效类型:迭代器、指针或引用失效意味着它们不能再安全地访问容器元素,可能导致未定义行为。
  • vector:由于连续内存,插入或删除可能导致数据搬移,特别是扩容时所有引用失效;否则,只影响插入点或删除点之后的元素。
  • deque:在头尾操作时,指针和引用通常保持有效(因为元素位置不变),但迭代器可能失效;在中间操作时,所有引用可能失效。
  • list/map/set :基于节点的结构,元素独立分配内存,插入不改变现有元素位置,删除只影响被删除元素本身。
    使用容器时,应避免在操作后使用失效的迭代器、指针或引用,以确保程序稳定性。

5. 总结

  1. 时刻警惕扩容 :只要调用了可能导致 vector 大小增加的函数(push_back, insert, resize),都要假设之前的迭代器全部失效。
  2. Erase 的返回值是救命稻草 :在循环中删除元素,必须接住 erase 的返回值。
  3. Visual Studio 的 Debug 模式 :VS 的 STL 实现(P.J. Plauger 版本)有非常严格的迭代器检查(Iterator Debugging),一旦使用失效迭代器会直接弹窗报错 Expression: vector iterator not incrementable,这是学习调试的好帮手,但在 Linux (GCC) 下可能只是默默地 Segfault。

补充:vector和string区别?vector能替代string吗

这是一个非常经典且具有实战意义的问题。

虽然从底层数据结构来看,std::stringstd::vector<char> 确实非常像(都是连续内存的动态数组),但在设计哲学、内存管理细节和应用场景上,它们有着本质的区别。

结论先行:

  1. 处理文本(Text)时std::string 是绝对的王者,vector 不能替代它。
  2. 处理二进制数据(Binary Data)时std::vector<char> (或 vector<uint8_t>) 才是首选,这时候反而不建议用 string

一、 核心区别对比表

特性 std::string std::vector
设计语义 表示"字符串/文本" 表示"字符数组/字节流"
内存结尾 保证以 \0 结尾 (C++11起) 不保证,只是单纯存数据
小对象优化 支持 SSO (栈上存储短字符串) 不支持 (始终在堆上分配)
接口丰富度 极其丰富 (find, substr, +, c_str) 仅基础容器操作 (push_back, insert)
C 语言兼容 c_str() 无缝兼容 C API 需要手动追加 \0 才能传给 C API

二、 深入剖析三大差异

1. "\0" 结尾符 (Null Terminator) ------ 最大的坑

这是 vector 无法替代 string 处理文本的最致命原因。

  • std::string :它是为了兼容 C 语言字符串 (const char*) 而生的。无论你怎么操作,string 内部永远会在有效数据的末尾偷偷维护一个 \0
  • std::vector :它只把 char 当作普通数据。存了 'H', 'i' 就是 'H', 'i',后面可能是随机乱码。

代码实战:

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <cstring>

int main() {
    // 1. String 的行为
    std::string s = "Hello";
    // s.c_str() 返回 "Hello\0",printf 安全打印
    printf("String: %s\n", s.c_str()); 

    // 2. Vector 的行为
    std::vector<char> v = {'H', 'e', 'l', 'l', 'o'};
    // v.data() 返回的是指向 'H' 的指针,但后面没有 \0
    // printf 会一直往后读内存,直到碰巧遇到一个 0,或者导致程序崩溃 (Segfault)
    // printf("Vector: %s\n", v.data()); // ❌ 极其危险!!

    // 必须手动补 \0
    v.push_back('\0'); 
    printf("Vector: %s\n", v.data()); // ✅ 现在才安全,但 size() 变了,很麻烦
}
2. SSO (Small String Optimization) ------ 性能杀手锏

vector 只有 3 个指针(24字节),数据全在堆(Heap)上。

但是 std::string 为了优化短字符串(比如 "name", "id", "hello"),引入了 SSO 机制

  • 机制 :如果字符串很短(通常 < 16 或 23 字节),直接存在栈上的 string 对象内部,完全不进行 new 堆内存分配
  • 对比vector<char> 哪怕只存一个字符,也要去堆上 new 一块内存。

性能影响

在处理大量短文本(如读取配置文件、解析 JSON key)时,stringvector<char> 快得多,因为没有堆内存分配的开销。

3. 接口能力的降维打击

std::string 提供了专门针对"文本处理"的方法,而 vector 只有"容器"方法。

如果你用 vector<char> 替代 string,你会发现:

  • 没有 + :不能写 v1 + v2,只能写 v1.insert(v1.end(), v2.begin(), v2.end())
  • **没有 substr**:想截取一段?自己算迭代器去拷贝吧。
  • **没有 find**:只能用 std::find 算法,而且还没法直接搜子串(比如搜 "abc"),只能搜单个字符。

三、 什么时候该用 vector 替代 string?

虽然处理文本不行,但在二进制数据处理 领域,vector 才是正确的选择。

场景:你要读取一张图片(JPG)、一个网络数据包、或者一个加密文件的内容。

为什么不用 string?

  1. 语义混淆string 让人误以为是文本,容易被同事误用(比如拿去打印、或者做字符编码转换)。
  2. \0 的歧义 :二进制数据中间可能包含数值 0x00(即 \0)。虽然 string 也能存 \0,但很多 C 语言函数(如 strlen, strcpy)遇到中间的 \0 就会截断数据,导致 Bug。
  3. 类型更准 :通常用 std::vector<uint8_t>(无符号字节)来表示二进制流,而 stringchar(有符号),在做位运算(如 >>, <<, &)时,char 可能会因为符号位导致计算错误。

总结

  • 文本、字符串、文件名、URL 请死心塌地使用 std::string
  • 图片数据、音频流、加密密文、Socket缓冲 请使用 std::vector<uint8_t>std::vector<char>

补充:={}初始化会隐式转换?先生成临时变量在拷贝复制?

这是一个非常专业且涉及到 C++ 版本演变 的问题。

答案是:视情况而定(取决于有没有等号 = 以及 C++ 的标准版本),但在现代 C++(C++17 及以后)中,结果是"零拷贝"的。

我们把 {} 初始化分为两种写法来剖析:

1. 写法一:不带等号 T a{1};

术语:直接列表初始化 (Direct List Initialization)

  • 隐式转换? 没有。它直接去匹配最合适的构造函数。
  • 中间临时对象? 绝无
  • 拷贝/移动? 绝无
  • 特权 :它可以调用 explicit(显式)构造函数。
cpp 复制代码
class A {
public:
    explicit A(int) {} // 必须显式调用
};

A a{1}; // ✅ 通过:直接调用构造函数,对象直接在 a 的内存地址上生成。

2. 写法二:带等号 T a = {1};

术语:拷贝列表初始化 (Copy List Initialization)

这里的情况比较复杂,经历了历史的演变:

A. 隐式转换?(语义层面)

是的。

虽然最终结果可能一样,但编译器会检查 这种转换是否合法。如果构造函数被声明为 explicit,这种写法会直接编译失败

cpp 复制代码
class A {
public:
    explicit A(int) {} 
};

A a = {1}; // ❌ 错误:需要隐式转换,但构造函数不允许
B. 先生成临时变量再拷贝?(物理层面)

这取决于你的 C++ 标准:

  • C++11 / C++14:

  • 理论上 :是的。编译器会先用 {1} 生成一个临时对象 (Temporary) ,然后再调用 拷贝构造 (或移动构造)把这个临时对象复制给 a

  • 实际上 :大多数编译器(GCC, Clang, MSVC)都会进行 拷贝省略 (Copy Elision) 优化,直接在 a 的内存里构造,把中间商消灭掉。

  • 硬性要求 :虽然编译器会优化掉拷贝,但标准要求你的类必须拥有 可访问的拷贝/移动构造函数。如果把拷贝构造设为 privatedelete,代码会报错。

  • C++17 及以后(强制省略):

  • 彻底变了 。标准引入了 保证拷贝省略 (Guaranteed Copy Elision)

  • 编译器不再"尝试"优化,而是强制 规定:T a = {1} 直接等同于 T a(1)

  • 中间临时对象不存在。即便在概念上也不存在。

  • 拷贝/移动构造函数不需要存在 。即使你的类不可拷贝、不可移动,只要能从 {1} 构造,这行代码就能跑。

3. 特殊情况:std::vectorstd::initializer_list

在写 vector 时,情况又不一样了。

cpp 复制代码
std::vector<int> v = {1, 2, 3};

这里发生了什么?这真的涉及到了"隐式转换"和"临时对象"。

  1. 临时数组 :编译器在幕后(静态存储区或栈上)偷偷生成了一个 int 数组 [1, 2, 3]
  2. 轻量级临时对象 :编译器构造了一个 std::initializer_list<int> 对象。这个对象非常小,只有两个指针(指向那个隐藏数组的头和尾)。
  3. 构造 Vector :这个临时的 initializer_list 被传给 vector 的构造函数。
  4. 深拷贝vector 的构造函数拿到这个 list,遍历它,把数据深拷贝vector 自己的堆内存中。

结论 :对于 vector 这种容器,= { ... } 确实涉及到了临时对象(那个隐藏数组和 list 句柄)以及数据的拷贝(从栈拷贝到堆)。

总结图解

假设有类 A,可以从 int 构造:

写法 C++11/14 行为 C++17 行为 隐式转换检查?
A a{1}; 直接构造 直接构造 ❌ 不检查 (explicit 可用)
A a = {1}; 临时对象 -> 移动/拷贝 (可优化) 直接构造 (强制) 检查 (explicit 报错)

你的疑惑解答:

"先生成临时变量再拷贝复制?"

  • C++17 之前理论上是(虽然会被优化)。
  • C++17 之后不是。没有临时变量,没有拷贝,直接落地生根。
  • 但是 :无论哪个版本,= {} 写法都要求构造函数不能是 explicit 的,这就是它"隐式"的一面。

第二章:vector的模拟实现

这是一份详细注释和整理后的 vector 模拟实现代码。我将代码分为了核心头文件 (vector.h) 和测试文件 (main.cpp),同时在关键逻辑处内嵌了详细的原理解释。

补充:内存分配与对象构造的分离

这是 C++ STL 实现中最底层、最硬核的设计哲学之一。

问题一:为什么不能直接 new T[N]

在 C++ 初学者眼中,申请数组就是 new T[N]。但在 std::vector 的实现者眼中,这种做法是绝对禁止的。

1. 核心矛盾:空间(Capacity)与 存在(Size)的分离

vector 有两个核心属性:

  • Capacity(容量):我们要了一块多大的地。
  • Size(大小):这块地上实际盖了几间房。

举个例子:

假设我们要创建一个存放 User 对象的 vector,我们预留了 100 个位置(reserve 100),但目前只插入了 3 个用户。

如果使用 new User[100]

  1. 强制构造 :编译器会立即调用 User默认构造函数 100 次!
  • 前 3 个是有用的。
  • 后 97 个是无用的"僵尸对象",浪费了大量的 CPU 算力。
  1. 强行限制 :如果 User没有默认构造函数 (比如构造函数必须传参 User(int id)),new User[100] 直接编译报错。你根本没法创建这种 vector。
2. 解决方案:内存分配与对象构造分离

为了解决上述问题,STL 采用两步走策略:

  • 步骤 A:只分配原生内存(Allocate)

    我们只向操作系统要一块"裸内存"(Raw Memory),里面全是随机的 0和1,不把它们当成对象看。

    • 使用 ::operator new(size)malloc
  • 步骤 B:按需构造对象(Construct)

    当用户真正调用 push_back 时,我们才在那块裸内存的指定位置上,建立一个对象。

    • 使用 Placement New
3. 什么是 Placement New?

它是 new 关键字的一种特殊用法,允许你在已经拥有的内存地址上构造对象,而不是去申请新内存。

语法:

cpp 复制代码
new (指针地址) 类型(构造参数);

代码对比:

cpp 复制代码
// 【错误做法】直接 new 数组
// 缺点:立即调用 10 次构造函数,且 T 必须有默认构造
T* data = new T[10]; 

// 【正确做法 - vector 的做法】
// 1. 分配内存 (只拿到 10 * sizeof(T) 大小的字节,不调用构造)
T* data = (T*)::operator new(sizeof(T) * 10);

// 2. 插入元素时,才在指定地址构造
// 在 data + 0 的位置,用 value 构造一个 T
new (data + 0) T(value); 
new (data + 1) T(value);

// 3. 析构时,必须手动调用析构函数
(data + 0)->~T();
(data + 1)->~T();

// 4. 最后释放内存
::operator delete(data);

问题二:POD 类型与非 POD 类型的处理策略 (类型萃取优化)

当你手写 vector 时,你会发现 copy(拷贝)、fill(填充)、destory(析构)这些操作非常频繁。这里存在巨大的性能优化空间。

1. 什么是 POD 类型?

POD (Plain Old Data),简单理解就是"老式的 C 语言风格数据"。

  • 特征:没有自定义构造函数、没有虚函数、没有自定义拷贝赋值运算符。
  • 例子int, double, char, 结构体 struct Point { int x; int y; }

非 POD 类型

  • 例子std::string, std::vector, 带有虚函数的类。
2. 为什么要区分它们?

场景:扩容或者拷贝 vector 时。

  • 对于 非 POD 类型 (如 std::string)
    你必须老老实实地写 for 循环,一个一个地调用拷贝构造函数。因为 string 内部有指针指向堆内存,直接复制字节会导致两个 string 指向同一块内存(浅拷贝),导致 Double Free 崩溃。
  • 对于 POD 类型 (如 int)
    构造函数是空的,析构函数也是空的。拷贝就是单纯的字节复制。
    优化手段 :直接使用 memcpymemmove

性能差距memcpy 是汇编级别的指令优化,比 for 循环一个个赋值要快几个数量级!

3. 类型萃取 (Type Traits) 是如何实现的?

C++ 标准库提供了 <type_traits> 头文件,可以在编译期就知道一个类型是不是 POD。

在 vector 的实现中,我们会编写类似这样的伪代码逻辑:

cpp 复制代码
// 析构函数的优化示例
template <typename T>
void destroy_range(T* begin, T* end) {
    // 编译期判断:T 是不是平凡类型 (Trivial/POD)
    if constexpr (std::is_trivially_destructible<T>::value) {
        // 如果是 int, float 等 POD 类型
        // 啥都不用做!直接 return,CPU 指令少了一大截
        return; 
    } else {
        // 如果是 string 等对象
        // 必须循环调用析构
        for (T* ptr = begin; ptr != end; ++ptr) {
            ptr->~T();
        }
    }
}

总结

在这一章中,我们必须向读者强调:专业的容器实现不仅仅是功能的堆砌,更是对系统性能的极致压榨。

  1. Placement New 解决了"先买地,后盖房"的问题,避免了无意义的构造开销和对默认构造函数的依赖。
  2. Type Traits (类型萃取) 让我们像开了"天眼"一样,在编译期识别数据类型。如果是 POD 类型,直接上 memcpy 这种"推土机"式的操作;如果是复杂对象,则使用精细的手工拷贝。这就是 STL 高效的秘密。

补充:std::initializer_list

std::initializer_list 是一个类模板,template<class T> class initializer_list;

深入理解 std::initializer_list 是掌握现代 C++(C++11 及以后)的关键一步。

它不仅仅是一个语法糖,它彻底改变了 C++ 对象的初始化方式。下面我们将从底层原理、使用方法、编译器行为、以及避坑指南四个维度详细讲解。

1. 什么是 std::initializer_list

在 C++11 之前,初始化一个 std::vector 非常痛苦:

cpp 复制代码
// C++98
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);

C++11 引入了 "统一初始化(Uniform Initialization)" ,允许使用大括号 {}。而 std::initializer_list 就是支撑这一语法的幕后核心类型

当编译器看到 {1, 2, 3, 4} 这样的一组数据,并且这组数据被用于构造对象或赋值时,编译器会悄悄把它构造成一个 std::initializer_list 对象。

2. 底层原理:它到底长什么样?

不要被它的名字骗了,它不是std::list 那样的链表,也不是像 std::vector 那样的容器。

它本质上是一个轻量级的"视图(View)"或"代理(Proxy)"。

它内部通常只有两个指针(或者一个指针 + 一个长度):

  1. _First:指向数据开始的地方。
  2. _Last :指向数据结束的地方(或者长度 _Len)。
编译器在幕后做了什么?

当你写出 auto il = {10, 20, 30}; 时,编译器执行了以下步骤:

  1. 开辟空间 :在内存中(通常是栈或者静态只读数据区)创建一个临时数组 ,里面存着 10, 20, 30
  2. 构造对象 :创建一个 std::initializer_list 对象,让它内部的指针指向这个临时数组的首尾。
  3. 只读保护 :这个临时数组里的元素被强制标记为 const,你绝对不能通过 initializer_list 修改里面的值。

3. 如何在你的类中支持它?

要在自定义的类(比如你写的 m::vector)中支持 {1, 2, 3} 初始化,你需要添加一个参数为 std::initializer_list<T> 的构造函数

cpp 复制代码
#include <initializer_list> // 必须包含头文件

template<class T>
class vector {
public:
    // ... 其他构造函数 ...

    // 支持 vector v = {1, 2, 3, 4, 5};
    vector(std::initializer_list<T> il) {
        // 1. 根据列表长度提前开空间 (il.size() 是自带的方法)
        reserve(il.size());

        // 2. 遍历列表,把数据拷贝到 vector 自己的内存里
        // initializer_list 支持迭代器遍历 (begin(), end())
        for (auto& e : il) {
            push_back(e);
        }
    }
};

关键点: initializer_list 不拥有数据的所有权,它只是借你看一眼。所以 vector 必须把数据深拷贝(Deep Copy)到自己的堆内存中。

4. 这里的坑:() vs {} 的优先级战争

这是 C++ 面试中的地狱难度题。当一个类同时拥有"普通构造函数"和"initializer_list 构造函数"时,编译器会极度偏向 initializer_list

只要你用了大括号 {},编译器就会拼命尝试匹配 initializer_list 版本。

案例分析
cpp 复制代码
class Test {
public:
    // 版本 A: 普通构造
    Test(int a, int b) { 
        cout << "普通构造 (int, int)" << endl; 
    }

    // 版本 B: 列表构造
    Test(std::initializer_list<int> il) { 
        cout << "列表构造 (initializer_list)" << endl; 
    }
};

Test t1(10, 20); // 调用 A -> 显而易见,是小括号,结果:[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
Test t2{10, 20}; // 调用 B -> 只要是 {},且类型能对上,必选 B,结果:[10, 1]

// 最坑的情况来了:
Test t3{10, 20.5}; // 编译器会怎么选?
  • 对于 t3 :编译器很想选 B (因为用了 {}),但是列表里有个 double (20.5),而 B 要求全是 int。且 {} 初始化不允许窄化转换(Narrowing Conversion,即不允许 double 截断成 int)。
  • 结果:编译器发现 B 走不通,甚至连 A 也可能会报错(取决于编译器对窄化转换的严格程度),或者退回到 A。

给你的 vector 提个醒:

这就是为什么 vector<int> v(10, 1) 创建了 10 个 1,而 vector<int> v{10, 1} 创建了两个元素(10 和 1)。前者是命令,后者是清单。

5. 致命陷阱:千万不要返回 initializer_list

因为 initializer_list 只是指向临时数组的"指针",这个临时数组的生命周期很短。

错误代码:

cpp 复制代码
std::initializer_list<int> func() {
    // 危险!
    // 1. 编译器在栈上创建了临时数组 {1, 2, 3}
    // 2. 返回了这个数组的"视图"
    // 3. 函数结束,栈被清空,数组销毁
    return {1, 2, 3}; 
}

int main() {
    auto il = func(); // il 指向了一块已经销毁的内存(野指针)
    cout << *il.begin(); // 未定义行为,程序可能崩溃或打印乱码
}

正确做法: 返回 std::vector<int>,利用值拷贝或移动语义带出数据。

6. 总结

  1. 身份std::initializer_list 是一个轻量级的对象,内部存着指向临时数组的指针。
  2. 作用 :让自定义类型也能像 C 数组一样使用 {...} 初始化。
  3. 权限 :里面的元素永远是 const 的,只读。
  4. 优先级 :使用 {} 初始化时,它的优先级最高。
  5. 生命周期:它不负责管理内存,不要将它作为返回值传出函数。

补充:指向同一块内存区域的不同指针是可以进行大小比较的

只要这两个指针指向的是同一个数组 (或者同一个连续内存块,比如 vector 里的空间),它们就可以进行 ><>=<= 比较。比的是"内存地址的编号数值(坐标)"

这背后的逻辑其实非常符合直觉,我用最通俗的生活案例和底层内存图解来解释。

1. 生活类比:门牌号

内存 想象成一条笔直的单行街道 ,把数组 想象成这条街上挨在一起的一排房子

  • vector 的第 0 个元素住在 100 号
  • vector 的第 1 个元素住在 104 号
  • vector 的第 2 个元素住在 108 号
    (注:因为 int 通常占 4 个字节,所以门牌号每次 +4)

指针是什么? 指针就是存着 "门牌号" 的变量。

  • 指针 p1 指向第 0 个元素,p1 的值就是 100
  • 指针 p2 指向第 2 个元素,p2 的值就是 108

比较指针大小是什么意思?

就是在问:108 大于 100 吗?

答案是肯定的。所以 p2 > p1 为真。

2. 内存图解

在计算机底层,内存地址就是一个线性增长的整数

cpp 复制代码
int arr[5] = {1, 2, 3, 4, 5};

int* p_start = &arr[0];  // 假设地址是 0x1000
int* p_end   = &arr[3];  // 假设地址是 0x100C (0x1000 + 3*4)

// 比较操作:
if (p_end > p_start) {
    // 成立!因为 0x100C > 0x1000
    // 物理意义:p_end 指向的元素在 p_start 的"后面"
}

3. 唯一的限制(重要!)

C++ 标准规定:只有指向"同一个数组(或对象)"的指针,才能安全地进行比较。

✅ 合法的情况:
cpp 复制代码
vector<int> v = {1, 2, 3};
int* p1 = &v[0];
int* p2 = &v[1];

if (p2 > p1) { ... } // ✅ 合法,肯定为真,因为 vector 内存是连续的
❌ 不合法(未定义行为)的情况:
cpp 复制代码
int a = 10;
int b = 20;
int* p1 = &a;
int* p2 = &b;

if (p1 > p2) { ... } // ❌ 危险!不要这样做!

为什么不行?

虽然 ab 都在栈上,都有地址,但在内存里谁前谁后是不确定的(取决于编译器怎么优化、怎么安排栈帧)。这种比较没有逻辑意义,结果也是不可预测的。

总结

vector 实现中:

  1. _start_finish 是一整块连续申请的内存。
  2. 所有的迭代器(pos, end)都指向这一块内存内部。
  3. 所以:它们完全可以像整数一样比大小,这非常安全且高效。

补充:左闭右开区间[begin, end)

对于 C++ STL 中所有的标准容器 (如 vector, list, deque, map, set, string 等)以及 C 语言风格的数组(通过 std::beginstd::end 访问),都严格遵循这个规则。

这在数学和计算机科学中被称为 "左闭右开区间" ,记作 [begin, end)

1. 核心图解

为了让你一眼看懂,我们可以画一个内存示意图。假设有一个 vector<int> v = {10, 20, 30};

text 复制代码
内存地址:   0x100      0x104      0x108      0x10C
          +----------+----------+----------+-----------+
数据:     |    10    |    20    |    30    | (无效区域) |
          +----------+----------+----------+-----------+
               ^                                ^
               |                                |
             v.begin()                        v.end()
          (指向第1个元素)                  (指向最后元素的下一个位置)
  • begin() :确确实实指向第一个有效元素(10)。
  • end() :指向的是 "尾后(Past-the-end)" 位置。它是一个哨兵位,不存储有效数据 ,绝对不能对 end() 进行解引用(*v.end() 是非法操作,会导致未定义行为或崩溃)。

2. 为什么要这样设计?(设计的哲学)

C++ 之父 Bjarne Stroustrup 选择这种设计不是为了刁难初学者,而是为了算法的通用性安全性

A. 极其优雅的"判空"逻辑

如果 end() 指向最后一个元素,那么空容器怎么表示?

  • 现在的设计 :只要 begin() == end(),就代表容器是空的。
  • 如果 end 指向最后一个元素:你需要特殊的标志来处理空容器,逻辑会变得复杂。
B. 完美的循环写法

这种设计让 for 循环的终止条件变得非常简单且不易出错:

cpp 复制代码
// 典型的 STL 遍历
for (auto it = v.begin(); it != v.end(); ++it) {
    cout << *it << endl; // 当 it 走到 end 时,循环正好结束,不会越界
}

这对应了数学上的区间:i 取值范围是 0 ≤ i < N 0 \le i < N 0≤i<N。。

C. 计算元素个数

对于随机访问迭代器(如 vector),元素个数可以直接用减法计算: S i z e = e n d ( ) − b e g i n ( ) Size = end() - begin() Size=end()−begin()

如果 end 指向最后一个元素,公式就得变成 (end - begin) + 1,容易忘记加 1。

3. 需要警惕的"陷阱"

虽然你说"所有容器",但在 C++ 中有几个特殊的家伙需要注意:

A. 容器适配器

std::stack(栈)、std::queue(队列)、std::priority_queue(优先队列)。

  • 它们 没有 begin()end()
  • 原因:这些数据结构的设计初衷就是限制访问(只能看头或尾),如果你能随便遍历它们,就破坏了栈和队列的"先进后出"或"先进先出"的原则。
B. 反向迭代器

当你使用 rbegin()rend() 时,逻辑是反过来的:

  • rbegin():指向最后一个元素。
  • rend():指向第一个元素的前一个位置(理论上的位置)。
  • 虽然方向反了,但依然保持了 "左闭右开" [rbegin, rend) 的区间思想。

总结

  • begin = 起跑线(包含)。
  • end = 终点线(不包含,踩到线就说明跑完了)。

第一部分:核心实现 (vector.h)

这部分代码模拟了 std::vector 的核心功能。重点在于内存管理(扩容机制)、深拷贝问题以及迭代器失效的处理。

cpp 复制代码
#pragma once
#include <new> // <--- 必须补上这行,否则 placement new 可能会报错
#include <assert.h>
#include <algorithm> // 用于 std::swap
#include <iostream>
#include <initializer_list> // 用于支持 C++11 的列表初始化 {1, 2, 3}

using namespace std;


namespace m
{
    // 模板类 vector,模拟 STL 的动态数组
    template<class T>
    class vector
    {
    public:
        // --- 核心类型定义 ---
        
        // vector 的物理内存是连续的,因此原生指针天然满足随机访问迭代器的所有要求
        // 支持 ++, --, *, ->, [], +, - 等操作
        typedef T* iterator;
        typedef const T* const_iterator;

        // --- 迭代器接口 ---

        // begin() 指向连续内存块的起始位置
        iterator begin() { return _start; }
        const_iterator begin() const { return _start; }

        // end() 指向有效数据的下一个位置(左闭右开区间)
        iterator end() { return _finish; }
        const_iterator end() const { return _finish; }

        // --- 构造函数与析构函数 ---

        // 1. 默认构造函数 (C++11)
        // 强制编译器生成默认的构造函数,成员变量会被初始化为 nullptr(因为我们在私有成员处给了缺省值)
        vector() = default;

        // 2. 迭代器区间构造函数 (Template)
        // 这是一个模板函数,目的是支持任意类型的容器迭代器初始化 vector
        // 例如:可以用 list<int>::iterator 或 string::iterator 来构造 vector<int>
        template <class InputIterator>
        vector(InputIterator first, InputIterator last)
        {
            // 遍历传入的区间,逐个 push_back
            while (first != last)
            {
                push_back(*first);
                ++first;
            }
        }

        // 3. 填充构造函数: vector(10, 5) -> 10个5
        // n: 元素个数, val: 初始值
        vector(size_t n, const T& val = T())
        {
            reserve(n); // 提前开辟空间,避免循环中频繁扩容,提高效率
            for (size_t i = 0; i < n; i++)
            {
                push_back(val);
            }
        }

        // 4. 重载 int 版本构造函数
        // 目的:解决 vector<int> v(10, 1) 的歧义问题。
        // 如果没有这个版本,(10, 1) 会被编译器优先匹配到上面的模板构造函数 template <class InputIterator>
      //  vector(InputIterator first, InputIterator last)
        // 导致编译错误(因为 int 不能解引用)。这个版本会精确匹配 (int, int)。
        vector(int n, const T& val = T())
        {
            reserve(n);
            for (int i = 0; i < n; i++)
            {
                push_back(val);
            }
        }

        // 5. C++11 初始化列表构造函数
        // 支持 vector<int> v = {1, 2, 3, 4, 5}; 这种写法
        vector(initializer_list<T> il)
        {
            reserve(il.size());
            for (auto e : il)
            {
                push_back(e);
            }
        }

        // 6. 拷贝构造函数: vector v2(v1)
        // 必须实现深拷贝!
        // 如果使用默认生成的拷贝构造(浅拷贝),两个 vector 会指向同一块内存,
        // 析构时会导致同一块内存被释放两次 (Double Free) 程序崩溃。
        vector(const vector<T>& v)
        {
            reserve(v.capacity()); // 先开辟足够大的空间
            for (auto e : v)
            {
                push_back(e); // 复用 push_back 逐个拷贝数据
            }
        }

        // 7. 赋值运算符重载: v1 = v3
        // 现代写法:利用"拷贝构造"和"交换"技术 (Copy and Swap idiom)
        // 参数 v 是 v3 的一份临时拷贝(传值传参,触发拷贝构造)
        vector<T>& operator=(vector<T> v) 
        {
            // 将 v1 的旧资源(this)与 v 的新资源交换
            swap(v);
            // 函数结束时,v 作为一个局部对象会自动析构,顺便把 v1 的旧内存释放掉
            return *this;
        }

        /* 8. 未实现内存分配与对象构造分离的析构函数
        ~vector()
        {
            if (_start)
            {
                delete[] _start; // 释放堆内存
                _start = _finish = _end_of_storage = nullptr;
            }
        }*/




// 实现内存分配与对象构造分离的析构函数
~vector() {
    if (_start) {
        // 【第一步:销毁对象】
        // 因为我们的内存是通过 operator new 申请的"裸内存"(Raw Memory),
        // 编译器不知道这块内存上存放了具体的 T 对象(比如 string)。
        // 所以,我们不能依赖 delete[] _start 自动帮我们调用析构函数。
        // 我们必须手动遍历每一个有效元素,显式调用它的析构函数。
        // 如果 T 是 std::string,这一步会释放 string 内部 malloc 的字符数组。
        // 如果跳过这一步直接释放内存,string 内部管理的资源就会泄露!
        
        // 注意:这里假设 clear() 的作用是循环调用 destructors。
        // 为了让你看懂,我把 clear() 的逻辑展开写在这里:
        T* cur = _start;
        while (cur != _finish) {
            cur->~T(); // 手动调用析构函数:释放对象持有的资源
            cur++;
        }

        // 【第二步:归还地皮】
        // ::operator delete 是 ::operator new 的逆操作。
        // 它只负责把这块内存空间(字节)还给操作系统。
        // 它完全不管内存里存的是什么,也不会调用析构函数(因为我们在第一步已经调过了)。
        ::operator delete(_start); 
//加上 ::,就是强制命令编译器:"不要看我类内部有没有重载,直接去调 C++ 标准库里那个最底层的、全局的释放函数"
//operator new 和 operator delete 是个特例。它们不在 std 里面,它们属于"全局作用域"(Global Scope)。
//C++ 标准库并不是把所有东西都塞进了 std 命名空间。虽然 95% 的东西都在 std 里,
//但为了兼容 C 语言以及支持底层机制,确实有一部分"法外狂徒"生活在 std 之外的全局作用域中。
        // 【第三步:置空指针】
        // 避免悬空指针(Dangling Pointer),这是一种良好的编程习惯。
        _start = _finish = _end_of_storage = nullptr;
    }
}





        // --- 容量与内存管理 ---

        size_t size() const { return _finish - _start; } // 指针相减获得个数
        size_t capacity() const { return _end_of_storage - _start; }

        // 交换两个 vector 的成员指针
        void swap(vector<T>& v)
        {
            std::swap(_start, v._start);
            std::swap(_finish, v._finish);
            std::swap(_end_of_storage, v._end_of_storage);
        }

        /*未实现分配内存与对象构造分离的 [核心]:扩容逻辑 reserve
        只有当 n > capacity() 时才扩容,reserve 绝不缩容
        void reserve(size_t n)
        {
            if (n > capacity())
            {
                size_t oldsize = size(); // 记录旧的大小,因为下面 _start 指针会变
                T* tmp = new T[n];       // 1. 开辟新内存块

                if (_start)
                {
                    // 2. 数据迁移(深拷贝)
                    // [严重警告]:这里绝对不能使用 memcpy!
                    // memcpy 是按字节浅拷贝。如果 T 是 std::string 等管理资源的类型,
                    // memcpy 只是复制了 string 内部的指针。
                    // 稍后 delete[] _start 会调用旧 string 的析构函数释放资源,
                    // 导致 tmp 中的 string 变成了指向已释放内存的野指针。
                    
                    // 正确做法:利用赋值运算符进行逐个拷贝(如果 T 是自定义类型,会调用其 operator=)
                    for (size_t i = 0; i < oldsize; i++)
                    {
                        tmp[i] = _start[i];
                    }

                    // 3. 释放旧空间
                    delete[] _start;
                }
                
                // 
                // 4. 更新指针指向新空间
                _start = tmp;
                _finish = _start + oldsize;
                _end_of_storage = _start + n;
            }
        }
        一:memcpy 复制后(危险的共享)
-----------------------------------------------------------
 旧 Vector (即将销毁)                     新 Vector (新家)
    [ string ]                             [ string ]
        |                                      |
        |  内部指针 (ptr = 0x100)               | 内部指针 (ptr = 0x100)
        +------------------+-------------------+
                           |
                           v
                  [ 堆内存: "Hello" ]  <-- (两个对象指向同一块内存!)

       


二:赋值复制后 (独立的个体)
-----------------------------------------------------------
 旧 Vector (即将销毁)                     新 Vector (新家)
    [ string ]                             [ string ]
        |                                      |
        | ptr = 0x100                          | ptr = 0x200 (新地址!)
        v                                      v
 [ 堆内存: "Hello" ]                    [ 堆内存: "Hello" ] 
                                           (这是克隆出来的副本)
*/



//实现分配内存与对象构造分离的 扩容函数:将容量扩大到 n
void reserve(size_t n) {
    // 只有当申请的 n 大于当前容量时才扩容(vector 只有增容,没有缩容机制)
    if (n > capacity()) {
        
        // 记录一下旧数据的大小,因为扩容后 _start 指针会变,到时候就算不出来了
        size_t oldsize = size();

        // ============================================================
        // 【第一步:申请新地皮(只申请内存,不构造对象)】
        // ============================================================
        // 1. 为什么不用 new T[n]?
        //    因为 new T[n] 会强制调用 n 次 T 的默认构造函数。
        //    如果 T 没有默认构造函数(比如 struct A { A(int x){...} };),代码会直接报错。
        //    而且,我们只需要一块空地,没必要现在就初始化出一堆无用的"僵尸对象"。
        //
        // 2. ::operator new 是什么?
        //    它等同于 C 语言的 malloc。它只负责向操作系统要 n * sizeof(T) 这么大的字节空间。
        //    此时,tmp 指向的内存里全是随机乱码,没有合法的 T 对象。
        T* tmp = (T*)::operator new(n * sizeof(T));//(T*) 是强制类型转换

        // ============================================================
        // 【第二步:搬家(将旧数据迁移到新内存)】
        // ============================================================
        if (_start) {
            for (size_t i = 0; i < oldsize; i++) {
                // --------------------------------------------------------
                // 核心难点:Placement New (定位 new)
                // 语法:new (地址) 类型 (参数);
                // 含义:不要申请新内存,请在 (tmp + i) 这个已有的地址上,构造一个 T 对象。
                // --------------------------------------------------------
                
                // std::move(_start[i]) 的作用:
                // 如果 T 是像 string 这样持有堆资源的类型,std::move 会把旧 string 的指针"偷"过来给新 string。
                // 这叫"移动构造",比"拷贝构造"(深拷贝)快得多,因为不用重新 malloc 字符串了。
                // 如果 T 是 int 这种简单类型,std::move 会自动退化成普通的拷贝。
                new (tmp + i) T(std::move(_start[i])); 

                // --------------------------------------------------------
                // 销毁旧房子
                // --------------------------------------------------------
                //既然资源已经移动到了新家(tmp),旧地址(_start)上的对象就没有利用价值了。
                // 但是旧对象生命周期结束了,必须手动调用析构函数,防止资源泄露。
                // (虽然对于 string 来说,move 后它变为空了,析构也没事,但这是标准流程)
                _start[i].~T(); 
            }

            // ========================================================
            // 【第三步:退还旧地皮】
            // ========================================================
            // 同样,只释放内存空间,不负责析构(因为上面循环里已经手动析构过了)。
            // 绝对不能用 delete[] _start,否则会再次触发析构函数,导致 Double Free 崩溃!
            ::operator delete(_start);
        }

        // ============================================================
        // 【第四步:更新指针】
        // ============================================================
        // 让三个核心指针指向新的"豪宅"
        _start = tmp;
        _finish = _start + oldsize;       // 有效数据结束的位置
        _end_of_storage = _start + n;     // 整个地皮结束的位置
    }
}

 // --- 元素访问 ---

        // 读写版本
        T& operator[](size_t i)
        {
            assert(i < size()); // 越界断言检查
            return _start[i];
        }

        // 只读版本
        const T& operator[](size_t i) const
        {
            assert(i < size());
            return _start[i];
        }

        // --- 修改操作 ---

        void push_back(const T& x)
        {
            // 复用 insert,在 end() 位置插入
            // 虽然可以直接写逻辑,但复用能减少代码冗余
            insert(end(), x);
        }

        void pop_back()
        {
            assert(size() > 0);
            --_finish; // 只需要移动尾指针,逻辑删除即可,不需要真正销毁内存
        }

        // [核心]:插入逻辑 insert
        // 在 pos 位置之前插入 x,返回新插入元素的迭代器
        iterator insert(iterator pos, const T& x)
        {
            assert(pos >= _start);
            assert(pos <= _finish);

            // 检查是否需要扩容
            if (_finish == _end_of_storage)
            {
                // [迭代器失效风险点 1]
                // 如果发生扩容,_start 会指向新地址。
                // 此时传入的参数 pos 仍然指向旧的那块已经被 delete 的内存(野指针)。
                // 所以必须先计算 pos 距离 _start 的相对偏移量 len。
                size_t len = pos - _start;

                size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
                reserve(newcapacity);

                // 扩容后,利用偏移量修正 pos,使其指向新内存中的对应位置
                pos = _start + len;
            }

            // 数据挪动:从后往前挪,给 pos 位置腾出空地
            // 图解:[1, 2, 3, _] -> insert at 1 -> [1, 2, 2, 3] -> [1, x, 2, 3]
            iterator end = _finish - 1;
            while (end >= pos)
            {
                *(end + 1) = *end;
                --end;
            }

            *pos = x;
            ++_finish;

            // 返回新元素的迭代器,解决外部迭代器失效问题
            return pos;
        }

        // [核心]:删除逻辑 erase
        // 删除 pos 位置的元素,返回被删除元素**下一个位置**的迭代器
        iterator erase(iterator pos)
        {
            assert(pos >= _start);
            assert(pos < _finish);

            // 数据挪动:从前往后挪,覆盖掉 pos 位置
            iterator it = pos + 1;
            while (it != _finish)
            {
                *(it - 1) = *it;
                ++it;
            }

            --_finish; // 逻辑大小减一
            
            // 返回 pos。因为数据前移了,原来的 pos 现在指向的就是下一个元素。
            return pos; 
        }

    private:
        // C++ STL vector 典型内存布局:三个指针
        // 
        iterator _start = nullptr;          // 指向内存块起始
        iterator _finish = nullptr;         // 指向有效数据的尾后
        iterator _end_of_storage = nullptr; // 指向内存块的容量尾后
    };
}

第二部分:测试用例 (main.cpp)

这部分代码不仅是测试,更是用例教学。展示了 vector 的正确用法以及必须避免的坑(特别是迭代器失效)。

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <vector>
#include <list>
#include <string>
#include <ctime>   // for clock()
#include <cstdio>  // for printf

#include "vector.h" // 包含我们上面实现的头文件

using namespace std;

// --- 测试函数集 ---

namespace m 
{
    // 测试1:基本功能、三种遍历方式
    void test_vector1()
    {
        m::vector<int> v1;
        v1.push_back(1);
        v1.push_back(2);
        v1.push_back(3);
        v1.push_back(4);

        // 方式1:下标访问 + size()
        for (size_t i = 0; i < v1.size(); i++)
        {
            cout << v1[i] << " ";
        }
        cout << endl;

        // 方式2:范围 for (语法糖,底层依赖 begin() 和 end())
        for (auto e : v1)
        {
            cout << e << " ";
        }
        cout << endl;

        // 方式3:迭代器显式遍历
        // m::vector<int>::iterator it = v1.begin();
        auto it = v1.begin(); 
        while (it != v1.end())
        {
            cout << *it << " ";
            ++it;
        }
        cout << endl;
    }

    // 测试2:Insert 与 迭代器失效的处理
    void test_vector2()
    {
        m::vector<int> v1;
        v1.push_back(1);
        v1.push_back(2);
        v1.push_back(3);
        v1.push_back(4);

        // 头插 0
        v1.insert(v1.begin(), 0);
        
        // 头删
        v1.erase(v1.begin());

        // [重点]:演示迭代器失效
        int x;
        cout << "请输入要查找并插入的值 (例如: 2): ";
        cin >> x;
        
        // 使用 std::find 算法查找
        m::vector<int>::iterator it = find(v1.begin(), v1.end(), x);
        if (it != v1.end())
        {
            // insert 可能会触发扩容。
            // 如果触发扩容,旧内存释放,it 指向野指针。
            // 解决:必须接收 insert 的返回值,它指向新内存中新插入元素的正确位置。
            it = v1.insert(it, 1000);

            // 此时 it 是有效的,可以安全访问
            cout << "Inserted value: " << *it << endl;
        }

        for (auto e : v1) cout << e << " ";
        cout << endl;
    }

    // 测试3:Erase 与 迭代器失效
    void test_vector3()
    {
        // 这里用 std::vector 做对比,标准库的行为也是一样的
        std::vector<int> v1; 
        v1.push_back(1);
        v1.push_back(2);
        v1.push_back(3);
        v1.push_back(4);
        v1.push_back(5);

        int x;
        cout << "请输入要删除的值 (例如: 3): ";
        cin >> x;
        auto it = find(v1.begin(), v1.end(), x);
        if (it != v1.end())
        {
            // erase 删除 it 指向的元素后,后续元素前移。
            // 某些编译器(如 VS Debug模式)会强制检查迭代器有效性,认为 it 已失效。
            // 正确写法:接收返回值,erase 返回被删除元素**之后**的那个位置的迭代器。
            it = v1.erase(it);

            if(it != v1.end())
                cout << "Next element: " << *it << endl;
        }
    }

    // 测试4:遍历中删除元素的正确姿势(面试常考)
    // 任务:删除所有偶数
    void test_vector4()
    {
        std::vector<int> v1;
        v1.push_back(1);
        v1.push_back(2);
        v1.push_back(3);
        v1.push_back(4);
        v1.push_back(5);

        auto it = v1.begin();
        while (it != v1.end())
        {
            if (*it % 2 == 0)
            {
                // 删除偶数,it 需要更新为 erase 的返回值(指向下一个元素)
                // 此时不要 ++it,因为 erase 已经让后面的元素补上来了
                it = v1.erase(it);
            }
            else
            {
                // 没有删除时才需要 ++
                ++it;
            }
        }
        
        cout << "After removing evens: ";
        for (auto e : v1) cout << e << " "; // 预期: 1 3 5
        cout << endl;
    }

    // 测试5:深拷贝测试 (Copy Constructor & Assignment)
    void test_vector5()
    {
        m::vector<int> v1;
        v1.push_back(1); v1.push_back(2); v1.push_back(3);

        m::vector<int> v2(v1); // 测试拷贝构造
        
        m::vector<int> v3;
        v3.push_back(10); v3.push_back(20);
        
        v1 = v3; // 测试赋值重载

        cout << "v1 (should be 10 20): ";
        for (auto e : v1) cout << e << " ";
        cout << endl;
    }

    // 测试7:构造函数匹配歧义解决
    void test_vector7()
    {
        // 调用 vector(size_t n, const T& val) -> 10个 "xxx"
        m::vector<string> v2(10, "xxx"); 
        cout << "v2 size: " << v2.size() << endl;

        // 如果没有 vector(int, const T&),下面这行会优先匹配模板构造函数 vector(InputIterator, InputIterator)
        // 因为 10 和 1 默认是 int 类型,模板匹配度更高。
        // 但我们在类中实现了 vector(int, const T&),所以这里会正确调用填充构造。
        m::vector<int> v4(10, 1); 
        
        cout << "v4 size: " << v4.size() << endl;
    }

    // 辅助类 A,用于测试隐式转换
    class A
    {
    public:
        A(int a1 = 0) :_a1(a1) {}
        A(int a1, int a2) :_a1(a1), _a2(a2) {}
    private:
        int _a1;
        int _a2;
    };

    // 测试8:C++11 初始化列表与类型转换
    void test_vector8()
    {
        // 隐式类型转换 A(1)
        A aa6 = {1}; 

        // 显式调用 initializer_list 构造
        m::vector<int> v1({ 1,2,3,4,5,6 }); 
        
        // 隐式类型转换:initializer_list -> vector
        m::vector<int> v2 = { 10, 20, 30 }; 

        // 混合测试:列表初始化 + 对象隐式转换
        // {1} -> A(1), {2,2} -> A(2,2)
        m::vector<A> v3 = { 1, A(1), A(2,2), {1}, {2,2} };
    }

    // 测试9:验证 Reserve 的深拷贝逻辑是否正确 (针对 string)
    void test_vector9()
    {
        m::vector<string> v1;
        // 插入长字符串,迫使 string 内部进行堆分配
        v1.push_back("111111111111111111"); 
        v1.push_back("222222222222222222");
        v1.push_back("333333333333333333");
        v1.push_back("444444444444444444");

        // 如果 reserve 实现中使用了 memcpy,扩容时只是复制了 string 的内部指针。
        // 旧内存 delete[] 时,string 析构释放了一次堆内存。
        // 新内存中的 string 指向了已被释放的空间。
        // 函数结束 v1 析构时,会再次释放同一块空间 -> Double Free Crash。
        // 如果这里能正常运行结束,说明深拷贝逻辑正确。
        cout << "String vector contents: ";
        for (auto& e : v1)
        {
            cout << e << " ";
        }
        cout << endl;
    }
}

// ----------------------------------------------------
// 性能测试对比 (std::vector vs std::list)
// ----------------------------------------------------
void test_op2()
{
    srand(time(0));
    const int N = 10000000; // 测试数据量:1000万

    std::vector<int> v;
    std::list<int> lt1;

    // 准备随机数据
    for (int i = 0; i < N; ++i)
    {
        auto e = rand() + i;
        v.push_back(e);
        lt1.push_back(e);
    }

    // 1. Vector 排序
    // vector 物理连续,CPU 缓存命中率高,支持随机访问。
    // std::sort 使用的是快排、堆排、插入排序的混合优化算法 (Introsort),效率极高。
    int begin1 = clock();
    sort(v.begin(), v.end());
    int end1 = clock();

    // 2. List 排序
    // list 物理不连续,不支持随机访问,只能用成员函数 sort (归并排序)。
    // 涉及大量的指针跳转,缓存极不友好。
    int begin2 = clock();
    lt1.sort();
    int end2 = clock();

    printf("vector sort cost: %d ms\n", end1 - begin1);
    printf("list sort cost: %d ms\n", end2 - begin2);

    // 典型输出差异:Vector 可能比 List 快 2-5 倍甚至更多。
    // 这证明了为什么 vector 是默认首选容器。
}

int main()
{
    // 运行测试
    // m::test_vector1();
    // m::test_vector2();
    // m::test_vector9();
    
    test_op2(); // 运行性能对比测试

    return 0;
}

第三章:深入理解 vector 实现二维数组 ------ vector<vector<T>>

在 C 语言中,我们习惯使用 int arr[M][N] 来定义二维数组。到了 C++,当我们需要一个行数和列数都可以动态变化 的二维矩阵时,标准做法是使用 vector 嵌套 vector,即 vector<vector<T>>

但这不仅仅是语法的改变,它在内存模型上与原生二维数组有着本质的区别。

1. 什么是 vector<vector<T>>

从类型定义的角度来看,vector<vector<int>> 的意思是:

  • 外层 vector:这是一个容器,它的每一个元素都是一个对象。
  • 内层元素 :这个对象恰好也是一个 vector<int>

简单来说,它是一个**"装着向量的向量"** 。

语法定义

cpp 复制代码
#include <vector>
using namespace std;

// 定义一个空的二维 vector
vector<vector<int>> matrix; 

2. 核心差异:内存模型

这是理解本章最重要的部分。

(1) 原生二维数组 (int arr[3][4])

C 语言的二维数组在内存中是连续的一块大内存。虽然逻辑上分行分列,但物理上是线性的。

  • arr[0][3] 的下一个地址紧接着就是 arr[1][0]

(2) vector 的二维数组 (vector<vector<int>>)

vector 的二维数组在内存中通常是不连续的。

  • 外层 vector :维护一块连续内存,存储的是内层 vector管理结构 (即 vector 对象的头部信息:指针、size、capacity,通常占 24 字节)。
  • 内层 vector :每一个内层 vector 自己在堆(Heap)上申请独立的内存块来存放实际的 int 数据。

结论

  1. 非连续性:第 0 行的数据和第 1 行的数据在物理内存上可能相隔十万八千里。
  2. 二次寻址 :访问 matrix[i][j] 时,CPU 需要先找到外层 matrix[i] 获取内层 vector 的头部,再通过头部指针找到堆上的数据区,最后访问下标 j

3. 初始化与大小控制

很多初学者容易在这里写出 Bug,因为二维 vector 需要对"行"和"列"分别初始化。

(1) 构造时指定大小(推荐)

如果你知道矩阵是 大小:

cpp 复制代码
int n = 5; // 行数
int m = 6; // 列数

// 语法:vector<T> v(n, val); 
// 这里的 T 是 vector<int>,val 是 vector<int>(m, 0)
vector<vector<int>> vv(n, vector<int>(m, 0));

(2) 使用初始化列表(C++11)

cpp 复制代码
vector<vector<int>> vv = {
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};

(3) 动态调整大小 (resize)

cpp 复制代码
vector<vector<int>> vv;
vv.resize(5); // 先开辟 5 个空行(每行都是空的 vector)

for(int i = 0; i < 5; ++i) {
    vv[i].resize(6); // 再把每一行扩容为 6 个元素
}

4. 特性:锯齿状数组

由于每个内层 vector 都是独立的,这意味着每一行的长度可以不一样。这在原生二维数组中是不可能做到的。

应用场景:杨辉三角、邻接表(图论)。

cpp 复制代码
// 创建一个锯齿状数组
vector<vector<int>> jagged;
jagged.push_back({1});       // 第0行有1个元素
jagged.push_back({1, 2});    // 第1行有2个元素
jagged.push_back({1, 2, 3}); // 第2行有3个元素

5. 遍历与访问

(1) 下标法(最像 C 语言)

cpp 复制代码
for (size_t i = 0; i < vv.size(); ++i) { // 遍历行
    for (size_t j = 0; j < vv[i].size(); ++j) { // 遍历列
        cout << vv[i][j] << " ";
    }
    cout << endl;
}

(2) 范围 for 循环(C++11,推荐)

注意使用引用 const auto& 避免深拷贝。

cpp 复制代码
// row 是 vector<int> 的引用
for (const auto& row : vv) {
    // val 是 int 的引用
    for (const auto& val : row) {
        cout << val << " ";
    }
    cout << endl;
}

6. 高阶思考:性能陷阱与优化

作为深入理解 C++ 的学习者,你需要知道 vector<vector<int>> 的潜在性能问题。

  1. Cache Miss(缓存未命中)
    由于每一行的数据在内存中不连续,CPU 预取机制在跨行遍历时效果会变差,导致缓存命中率降低,处理超大规模矩阵时速度不如原生数组。
  2. 多次动态内存分配
    创建一个 1000 × 1000 1000 \times 1000 1000×1000 的二维 vector,需要调用 1001 次 new(1 次外层,1000 次内层),开销巨大。

优化方案:一维 vector 模拟二维

在高性能计算(如图像处理、深度学习)中,我们通常使用一维 vector来模拟二维。

cpp 复制代码
// 模拟 10x10 的矩阵
int rows = 10, cols = 10;
vector<int> matrix(rows * cols); 

// 访问 (x, y) 元素
// 公式:index = 行号 * 列数 + 列号
int val = matrix[x * cols + y]; 

优点

  • 内存绝对连续。
  • 只有一次内存分配。
  • 对 CPU 缓存极其友好。

7. 总结

  1. vector<vector<T>> 是 C++ 中实现动态二维数组的标准方式。
  2. 它的本质是容器的嵌套,每一行独立管理自己的内存。
  3. 它支持锯齿状结构(每行长度不同)。
  4. 要注意 begin()end() 是针对某一层的,vv.begin() 指向的是第一行(vector对象),而不是第一个 int。
  5. 在极度追求性能的场景下,考虑用一维 vector + 索引计算来替代它。

补充:sore

std::sort 是 C++ 标准库(STL)中最强大、最常用,也是面试中最常考的算法之一。

它不仅仅是一个简单的排序函数,它的底层实现(通常是 Introsort)凝聚了计算机科学几十年来对排序算法优化的智慧。

以下是关于 std::sort 的全方位深度解析,包含用法、底层原理、性能秘密以及面试中的致命陷阱。

第一部分:基础用法

std::sort 定义在 <algorithm> 头文件中。

1. 函数原型

它通常有两个版本:

  1. 默认版本 :使用 < 运算符进行比较(默认升序)。
  2. 自定义比较器版本:使用你提供的函数对象(Functor)、函数指针或 Lambda 表达式进行比较。
cpp 复制代码
// 版本 1
template< class RandomIt >
void sort( RandomIt first, RandomIt last );

// 版本 2
template< class RandomIt, class Compare >
void sort( RandomIt first, RandomIt last, Compare comp );

注意 :它只接受 随机访问迭代器 (RandomAccessIterator) 。这意味着它支持 vector, deque, array 和原生数组,但不支持 listforward_list(链表需调用成员函数 list::sort)。

2. 代码实战

cpp 复制代码
#include <algorithm>
#include <vector>
#include <iostream>
#include <functional> // for std::greater

struct Student {
    std::string name;
    int score;
};

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

    // 1. 默认升序 (从小到大)
    std::sort(v.begin(), v.end()); 
    // v: 1, 2, 3, 4, 5

    // 2. 降序 (使用标准库提供的仿函数)
    std::sort(v.begin(), v.end(), std::greater<int>());
    // v: 5, 4, 3, 2, 1

    // 3. 自定义结构体排序 (使用 Lambda)
    std::vector<Student> cls = {{"Tom", 80}, {"Jerry", 90}, {"Spike", 85}};
    
    // 按分数降序排
    std::sort(cls.begin(), cls.end(), [](const Student& a, const Student& b) {
        return a.score > b.score; // 谁大谁排前面
    });
}

第二部分:底层原理 ------ 内省排序

这是最硬核的部分。很多初学者以为 std::sort 就是"快速排序 (Quicksort)",但这并不准确。

真相std::sort 使用的是一种混合排序算法 ,称为 Introsort (Introspective Sort)

1. 为什么要混合?

  • 快速排序 (Quicksort) :平均 O ( N log ⁡ N ) O(N \log N) O(NlogN) ,非常快。但在最坏情况(Pivot 选得不好)会退化成 O ( N 2 ) O(N^2) O(N2)。
  • 堆排序 (Heapsort) :虽然平均速度比快排稍慢(缓存命中率低),但它保证最坏情况也是 O ( N log ⁡ N ) O(N \log N) O(NlogN)。
  • 插入排序 (Insertion Sort):对于极小的数据集(例如 < 16 个元素),它的效率极高,因为它没有递归调用的开销,且对局部有序数据非常友好。

2. Introsort 的工作流程

Introsort 像一个聪明的指挥官,根据战况动态切换策略:

  1. 开局 :默认使用 快速排序 进行递归分割。

  2. 监控:它会监控递归的深度。

    • 如果递归深度太深(超过 log ⁡ N \log N logN 的某个倍数),说明快排可能要把自己玩坏了(遇到了恶化数据)。
    • 切换 :立即改用 堆排序 处理剩余的子区间。这保证了时间复杂度永远不会退化到 O ( N 2 ) O(N^2) O(N2)。
  3. 收尾:当递归下来的子区间非常小(通常是 16 个元素左右)时,停止递归。

    • 切换 :最后对整个基本有序的数组进行一次 插入排序

图解算法流程

总结公式:

std::sort = QuickSort + HeapSort + InsertionSort \text{std::sort} = \text{QuickSort} + \text{HeapSort} + \text{InsertionSort} std::sort=QuickSort+HeapSort+InsertionSort

第三部分:为什么它比 C 语言的 qsort 快?

这是一个经典的 C++ 面试题:为什么 std::sort 通常比 C 标准库的 qsort 快很多(有时快 6-10 倍)?

两者都是 ,差距在哪里?

1. 内联 (Inlining) vs 函数指针

  • qsort (C 语言) :你需要传一个函数指针 int (*comp)(const void*, const void*) 给它。

  • 在排序过程中,qsort 需要进行亿万次比较。每一次比较都是通过函数指针调用(Indirect Call)。

  • 这导致编译器无法进行内联优化(Inline),CPU 的流水线也会被频繁打断。

  • std::sort (C++ 模板)

  • 比较器(Functor 或 Lambda)是在编译期确定的。

  • 编译器会将比较逻辑直接内联展开到排序代码中。

  • 结果:没有函数调用开销,就像你手写了一个专门针对 int 的排序函数一样。

2. 类型安全 vs void*

  • qsort 使用 void*,在比较函数内部必须强制转换类型,这有运行时开销且不安全。
  • std::sort 是模板,直接操作具体类型。

第四部分:致命陷阱 ------ 严格弱序 (Strict Weak Ordering)

这是使用 std::sort 时导致程序崩溃(Segfault)或死循环的头号原因

当你编写自定义比较函数 comp(a, b) 时,必须满足 严格弱序 规则。最通俗的理解就是:一定要表现得像小于号 <

❌ 经典错误案例

你想降序排列,或者允许相等元素交换,于是写了:

cpp 复制代码
// 错误写法!
bool comp(int a, int b) {
    return a <= b; // 包含了"等于"的情况
}

🔥 为什么会崩?

假设有两个相等的元素 ab(即 a == b)。

  1. 你问 comp(a, b),函数返回 true(因为 a <= b)。
  2. std::sort 内部逻辑可能会问反向情况 comp(b, a),函数也返回 true

逻辑矛盾 :算法认为 a < b 成立,且 b < a 也成立。这在逻辑上是不可能的!这会导致快速排序的指针越界,或者导致无限递归。

✅ 正确法则

比较函数在相等时必须返回 false

cpp 复制代码
bool comp(int a, int b) {
    return a < b; // 正确
    // return a > b; // 正确(降序)
}

第五部分:std::sort vs std::stable_sort

std::sort不稳定的排序。

1. 什么是稳定性?

如果有两个元素的 Key 相等(例如按分数排序,两人都是 90 分),排序后,它们原本的相对位置是否保持不变?

  • 保持不变 = 稳定 (Stable)
  • 可能改变 = 不稳定 (Unstable)

2. 什么时候用 std::stable_sort

当你需要多级排序时。例如:先按"班级"排,再按"分数"排。如果不稳定,按分数排完后,原本按班级排好的顺序可能就乱了。

  • std::sort:基于快排,不稳定。
  • std::stable_sort :通常基于 归并排序 (Merge Sort)。它需要分配额外的内存 buffer。如果内存不够,它会退化到 的算法,但依然保证稳定。

图解稳定性

第六部分:std::lessstd::greater

std::lessstd::greater 是 C++ 标准库中两个非常基础但极其重要的仿函数

它们定义在头文件 <functional> 中。

你可以把它们简单理解为:C++ 官方为你封装好的"小于号"和"大于号" 。它们最主要的作用就是作为参数传给 std::sortstd::mapstd::priority_queue 等,用来控制排序顺序数据排列规则

1. 一张表看懂区别

名字 对应的数学符号 逻辑含义 std::sort 中的效果 priority_queue 中的效果
std::less<T> < 左边 小于 右边时返回 true 升序 (从小到大) (默认行为) 大顶堆 (最大的在上面) (这是反直觉的坑点)
std::greater<T> > 左边 大于 右边时返回 true 降序 (从大到小) 小顶堆 (最小的在上面)

2. 底层长什么样?(源码级理解)

它们真的非常简单,本质上就是结构体里重载了 operator()

cpp 复制代码
// 简化版源码示意
namespace std {

    // std::less - 也就是"升序"逻辑
    template <typename T>
    struct less {
        // 重载 () 操作符,让结构体对象能像函数一样被调用
        bool operator()(const T& x, const T& y) const {
            return x < y; // 核心就这一行
        }
    };

    // std::greater - 也就是"降序"逻辑
    template <typename T>
    struct greater {
        bool operator()(const T& x, const T& y) const {
            return x > y; // 核心就这一行
        }
    };
}

为什么叫"仿函数"?

因为你创建一个对象 std::less<int> op; 后,你可以像调用函数一样写 op(1, 2),它会返回 true

3. 实战用法

场景 A:在 sort 中切换升降序

这是最常用的场景。

cpp 复制代码
#include <iostream>
#include <algorithm> // std::sort
#include <vector>
#include <functional> // std::less, std::greater

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

    // 1. 默认情况 (其实就是偷偷用了 std::less)
    // 效果:1, 2, 3, 4, 5 (升序)
    std::sort(v.begin(), v.end()); 
    // 等价于: std::sort(v.begin(), v.end(), std::less<int>());

    // 2. 改为降序 (使用 std::greater)
    // 效果:5, 4, 3, 2, 1 (降序)
    // 注意:后面要加 (),因为我们要传的是一个"对象实例"
    std::sort(v.begin(), v.end(), std::greater<int>()); //std::greater<int>() 的确就是创建了一个临时匿名对象

    return 0;
}
场景 B:自定义类型的使用

如果你想对自定义的 Student 类使用 greater 进行降序排序,你的 Student 类必须重载 > 运算符。

cpp 复制代码
struct Student {
    int score;
    
    // 重载 > 号,以便 std::greater 能工作
    bool operator>(const Student& other) const {
        return this->score > other.score;
    }
    
    // 重载 < 号,以便 std::less (默认sort) 能工作
    bool operator<(const Student& other) const {
        return this->score < other.score;
    }
};

std::vector<Student> cls = {{80}, {90}, {60}};

// 降序:需要 Student 类实现了 operator>
std::sort(cls.begin(), cls.end(), std::greater<Student>());

4. 两个著名的"坑"

坑一:priority_queue 的反直觉

std::priority_queue (优先队列) 默认是 大顶堆 (Max Heap),也就是最大的元素在队头。

但是!它的默认比较器竟然是 std::less

  • std::less (默认) 对应 大顶堆 (pop出来是最大的)。
  • std::greater 对应 小顶堆 (pop出来是最小的)。

原因 :STL 的堆算法逻辑是"如果 A < B,那么 B 应该排在 A 前面(或者上面)"。所以 less 导致大的在上面。

cpp 复制代码
// 创建一个小顶堆(最小元素优先出队)
// 必须写全三个模板参数:类型, 容器, 比较器
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
坑二:C++14 的透明比较器 (Transparent Comparator)

在 C++14 之前,你必须写 std::greater<int>(),要把 <int> 写清楚。

从 C++14 开始,你可以直接写 std::greater<>() (也就是 std::greater<void>)。

推荐写法 (C++14+):

cpp 复制代码
// 编译器会自动推导类型,甚至比指定类型更快、更通用
std::sort(v.begin(), v.end(), std::greater<>()); 

总结

  1. std::less = < = 升序 (默认)。
  2. std::greater = > = 降序
  3. 它们在 <functional> 头文件中。
  4. 使用时记得在后面加 (),因为你需要构造一个对象传进去,例如 std::greater<int>()

第七部分:总结速查

特性 std::sort
底层算法 Introsort (快排 + 堆排 + 插排)
时间复杂度 平均 ,最坏
空间复杂度 (递归栈空间)
稳定性 不稳定 (需要稳定请用 stable_sort)
迭代器要求 随机访问迭代器 (vector/deque/array)
比较器要求 必须满足 严格弱序 (a==b 时必须返回 false)
性能关键 利用 内联优化 ,远快于 qsort

一句话总结:
std::sort 是 C++ 赋予你的屠龙刀。它既有快排的速度,又有堆排的兜底稳定性,还有插排的局部微操。只要记得写对比较函数(不要带等于号),它就是无敌的。

相关推荐
兵哥工控2 小时前
mfc静态文本控件背景及字体颜色设置实例
c++·mfc
hqzing2 小时前
C语言程序调用syscall的几种方式
linux·c++
迟熙2 小时前
你的return,真的return对了吗?
c++
superman超哥2 小时前
仓颉内存分配优化深度解析
c语言·开发语言·c++·python·仓颉
2401_841495642 小时前
并行程序设计与实现
c++·python·算法·cuda·mpi·并行计算·openmp
chenyuhao20242 小时前
Linux系统编程:多线程同步与单例模式
linux·服务器·c++·后端·单例模式
曼巴UE52 小时前
UE C++ FName, FText 测试
服务器·c++·ue5
宠..3 小时前
QButtonGroup
java·服务器·开发语言·前端·数据库·c++·qt
superman超哥3 小时前
仓颉代码内联策略深度解析
c语言·开发语言·c++·python·仓颉