文章目录
- 前言
- 第一章:[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?)
- 补充:迭代器失效
-
- [1. 什么是迭代器失效?](#1. 什么是迭代器失效?)
- [2. 为什么会失效?(底层原因)](#2. 为什么会失效?(底层原因))
- [3. 最经典的错误案例:遍历中删除](#3. 最经典的错误案例:遍历中删除)
- [4. 扩展:不同容器的失效规则对比](#4. 扩展:不同容器的失效规则对比)
- [5. 总结](#5. 总结)
- 补充: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::vector和std::initializer_list) - 总结图解
- [1. 写法一:不带等号 `T a{1};`](#1. 写法一:不带等号
- 第二章: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) 是如何实现的?)
- 总结
- [问题一:为什么不能直接 `new T[N]`?](#问题一:为什么不能直接
- 补充:[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. 什么是 `std::initializer_list`?](#1. 什么是
- 补充:指向同一块内存区域的不同指针是可以进行大小比较的
-
- [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>>))
- [(1) 原生二维数组 (`int arr[3][4]`)](#(1) 原生二维数组 (
- [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. 总结)
- [1. 什么是 `vector<vector<T>>`?](#1. 什么是
- 补充: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::less和std::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 是一个能够自动管理内存的动态数组。
它具有以下核心特征:
- 连续内存:它的元素在物理内存中是连续存放的(和 C 数组一样)。这意味着它对 CPU 缓存(Cache)极其友好,访问速度极快。
- 动态增长 :你不需要像定义
int arr[10]那样把长度写死。vector 会根据需要自动申请更大的内存。 - 随机访问 :支持 时间复杂度的下标访问(即
v[5])。
3. 骨架:vector 的底层内存模型
std::vector 对象本身(即栈上的那个变量)非常小,通常只包含 3 个指针(在 64 位系统下通常占 24 字节)。
它并不直接存储数据,而是指挥堆(Heap)上的内存。这 3 个指针分别是:
_start(或_Myfirst) :指向已分配内存的起始位置。_finish(或_Mylast) :指向有效数据的下一个位置(即最后一个元素的后面)。_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() 时,就会触发扩容。
步骤如下:
- 开辟新空间 :通常按原容量的 2倍 (GCC)或 1.5倍(MSVC)申请一块新的连续内存。
- 数据搬家:将旧内存中的数据拷贝(或移动)到新内存。
- 释放旧空间:销毁旧内存块。
- 更新指针 :将
_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_back或insert元素时,如果size == capacity,vector 会触发自动扩容。 - 底层动作:
new一块更大的新内存。- 把旧数据
copy/move到新内存。 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. 总结
- 时刻警惕扩容 :只要调用了可能导致 vector 大小增加的函数(
push_back,insert,resize),都要假设之前的迭代器全部失效。 - Erase 的返回值是救命稻草 :在循环中删除元素,必须接住
erase的返回值。 - Visual Studio 的 Debug 模式 :VS 的 STL 实现(P.J. Plauger 版本)有非常严格的迭代器检查(Iterator Debugging),一旦使用失效迭代器会直接弹窗报错
Expression: vector iterator not incrementable,这是学习调试的好帮手,但在 Linux (GCC) 下可能只是默默地 Segfault。
补充:vector和string区别?vector能替代string吗
这是一个非常经典且具有实战意义的问题。
虽然从底层数据结构来看,std::string 和 std::vector<char> 确实非常像(都是连续内存的动态数组),但在设计哲学、内存管理细节和应用场景上,它们有着本质的区别。
结论先行:
- 处理文本(Text)时 :
std::string是绝对的王者,vector不能替代它。 - 处理二进制数据(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)时,string 比 vector<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?
- 语义混淆 :
string让人误以为是文本,容易被同事误用(比如拿去打印、或者做字符编码转换)。 \0的歧义 :二进制数据中间可能包含数值0x00(即\0)。虽然string也能存\0,但很多 C 语言函数(如strlen,strcpy)遇到中间的\0就会截断数据,导致 Bug。- 类型更准 :通常用
std::vector<uint8_t>(无符号字节)来表示二进制流,而string是char(有符号),在做位运算(如>>,<<,&)时,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的内存里构造,把中间商消灭掉。 -
硬性要求 :虽然编译器会优化掉拷贝,但标准要求你的类必须拥有 可访问的拷贝/移动构造函数。如果把拷贝构造设为
private或delete,代码会报错。 -
C++17 及以后(强制省略):
-
彻底变了 。标准引入了 保证拷贝省略 (Guaranteed Copy Elision)。
-
编译器不再"尝试"优化,而是强制 规定:
T a = {1}直接等同于T a(1)。 -
中间临时对象 :不存在。即便在概念上也不存在。
-
拷贝/移动构造函数 :不需要存在 。即使你的类不可拷贝、不可移动,只要能从
{1}构造,这行代码就能跑。
3. 特殊情况:std::vector 和 std::initializer_list
在写 vector 时,情况又不一样了。
cpp
std::vector<int> v = {1, 2, 3};
这里发生了什么?这真的涉及到了"隐式转换"和"临时对象"。
- 临时数组 :编译器在幕后(静态存储区或栈上)偷偷生成了一个
int数组[1, 2, 3]。 - 轻量级临时对象 :编译器构造了一个
std::initializer_list<int>对象。这个对象非常小,只有两个指针(指向那个隐藏数组的头和尾)。 - 构造 Vector :这个临时的
initializer_list被传给vector的构造函数。 - 深拷贝 :
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]:
- 强制构造 :编译器会立即调用
User的默认构造函数 100 次!
- 前 3 个是有用的。
- 后 97 个是无用的"僵尸对象",浪费了大量的 CPU 算力。
- 强行限制 :如果
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) :
构造函数是空的,析构函数也是空的。拷贝就是单纯的字节复制。
优化手段 :直接使用memcpy或memmove。
性能差距 :
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();
}
}
}
总结
在这一章中,我们必须向读者强调:专业的容器实现不仅仅是功能的堆砌,更是对系统性能的极致压榨。
- Placement New 解决了"先买地,后盖房"的问题,避免了无意义的构造开销和对默认构造函数的依赖。
- 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)"。
它内部通常只有两个指针(或者一个指针 + 一个长度):
_First:指向数据开始的地方。_Last:指向数据结束的地方(或者长度_Len)。
编译器在幕后做了什么?
当你写出 auto il = {10, 20, 30}; 时,编译器执行了以下步骤:
- 开辟空间 :在内存中(通常是栈或者静态只读数据区)创建一个临时数组 ,里面存着
10, 20, 30。 - 构造对象 :创建一个
std::initializer_list对象,让它内部的指针指向这个临时数组的首尾。 - 只读保护 :这个临时数组里的元素被强制标记为
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. 总结
- 身份 :
std::initializer_list是一个轻量级的对象,内部存着指向临时数组的指针。 - 作用 :让自定义类型也能像 C 数组一样使用
{...}初始化。 - 权限 :里面的元素永远是
const的,只读。 - 优先级 :使用
{}初始化时,它的优先级最高。 - 生命周期:它不负责管理内存,不要将它作为返回值传出函数。
补充:指向同一块内存区域的不同指针是可以进行大小比较的
只要这两个指针指向的是同一个数组 (或者同一个连续内存块,比如 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) { ... } // ❌ 危险!不要这样做!
为什么不行?
虽然 a 和 b 都在栈上,都有地址,但在内存里谁前谁后是不确定的(取决于编译器怎么优化、怎么安排栈帧)。这种比较没有逻辑意义,结果也是不可预测的。
总结
在 vector 实现中:
_start到_finish是一整块连续申请的内存。- 所有的迭代器(
pos,end)都指向这一块内存内部。 - 所以:它们完全可以像整数一样比大小,这非常安全且高效。
补充:左闭右开区间[begin, end)
对于 C++ STL 中所有的标准容器 (如 vector, list, deque, map, set, string 等)以及 C 语言风格的数组(通过 std::begin 和 std::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数据。
结论:
- 非连续性:第 0 行的数据和第 1 行的数据在物理内存上可能相隔十万八千里。
- 二次寻址 :访问
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>> 的潜在性能问题。
- Cache Miss(缓存未命中) :
由于每一行的数据在内存中不连续,CPU 预取机制在跨行遍历时效果会变差,导致缓存命中率降低,处理超大规模矩阵时速度不如原生数组。 - 多次动态内存分配 :
创建一个 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. 总结
vector<vector<T>>是 C++ 中实现动态二维数组的标准方式。- 它的本质是容器的嵌套,每一行独立管理自己的内存。
- 它支持锯齿状结构(每行长度不同)。
- 要注意
begin()和end()是针对某一层的,vv.begin()指向的是第一行(vector对象),而不是第一个 int。 - 在极度追求性能的场景下,考虑用一维 vector + 索引计算来替代它。
补充:sore
std::sort 是 C++ 标准库(STL)中最强大、最常用,也是面试中最常考的算法之一。
它不仅仅是一个简单的排序函数,它的底层实现(通常是 Introsort)凝聚了计算机科学几十年来对排序算法优化的智慧。
以下是关于 std::sort 的全方位深度解析,包含用法、底层原理、性能秘密以及面试中的致命陷阱。
第一部分:基础用法
std::sort 定义在 <algorithm> 头文件中。
1. 函数原型
它通常有两个版本:
- 默认版本 :使用
<运算符进行比较(默认升序)。 - 自定义比较器版本:使用你提供的函数对象(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和原生数组,但不支持list或forward_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 像一个聪明的指挥官,根据战况动态切换策略:
-
开局 :默认使用 快速排序 进行递归分割。
-
监控:它会监控递归的深度。
- 如果递归深度太深(超过 log N \log N logN 的某个倍数),说明快排可能要把自己玩坏了(遇到了恶化数据)。
- 切换 :立即改用 堆排序 处理剩余的子区间。这保证了时间复杂度永远不会退化到 O ( N 2 ) O(N^2) O(N2)。
-
收尾:当递归下来的子区间非常小(通常是 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; // 包含了"等于"的情况
}
🔥 为什么会崩?
假设有两个相等的元素 a 和 b(即 a == b)。
- 你问
comp(a, b),函数返回true(因为 a <= b)。 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::less 和 std::greater
std::less 和 std::greater 是 C++ 标准库中两个非常基础但极其重要的仿函数。
它们定义在头文件 <functional> 中。
你可以把它们简单理解为:C++ 官方为你封装好的"小于号"和"大于号" 。它们最主要的作用就是作为参数传给 std::sort、std::map、std::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<>());
总结
std::less=<= 升序 (默认)。std::greater=>= 降序。- 它们在
<functional>头文件中。 - 使用时记得在后面加
(),因为你需要构造一个对象传进去,例如std::greater<int>()。
第七部分:总结速查
| 特性 | std::sort |
|---|---|
| 底层算法 | Introsort (快排 + 堆排 + 插排) |
| 时间复杂度 | 平均 ,最坏 |
| 空间复杂度 | (递归栈空间) |
| 稳定性 | 不稳定 (需要稳定请用 stable_sort) |
| 迭代器要求 | 随机访问迭代器 (vector/deque/array) |
| 比较器要求 | 必须满足 严格弱序 (a==b 时必须返回 false) |
| 性能关键 | 利用 内联优化 ,远快于 qsort |
一句话总结:
std::sort 是 C++ 赋予你的屠龙刀。它既有快排的速度,又有堆排的兜底稳定性,还有插排的局部微操。只要记得写对比较函数(不要带等于号),它就是无敌的。