C++ 杂记之三从基础到进阶

大纲

容器

string 容器

string 的概念

string 是 STL 的字符串类型,通常用来表示字符串。而在使用 string 之前,字符串通常是用 char* 表示的。stringchar* 都可以用来表示字符串,两者的区别如下:

  • string 是一个类,char* 是一个指向字符的指针
  • string 封装了 char* 来管理字符串,本质是一个 char* 类型的容器
  • string 不用考虑内存释放和越界的问题
  • string 负责管理 char* 所分配的内存。每一次 string 的复制,取值都由 string 类负责维护,不用担心复制越界和取值越界等问题
  • string 提供了一系列的字符串操作函数,例如:查找(find)、拷贝(copy)、删除(erase)、替换(replace)、插入(insert)

string 的构造函数

  • 默认构造函数:string();
  • 带参数的构造函数:
    • string(const char *s);,用字符串 s 初始化
    • string(int n, char c);,用 n 个字符 c 初始化
  • 拷贝构造函数:string(const string &str);

字符串头文件说明

  • 在标准 C++ 中,使用 string 必须显式包含 <string> 头文件。任何在未包含 <string> 的情况下仍然能够使用 string 的代码,都依赖于标准库实现的间接包含行为,属于非标准、不可移植的用法。
  • 一些标准库实现(例如 libstdc++)在 <iostream> 等头文件的内部实现中,可能间接包含了 <string>,从而使代码在特定编译器和版本下 "看起来可以正常工作"。然而,这种行为是实现细节,不受 C++ 标准保证,随着编译器、标准库版本或编译选项(如不同的 -std= 标准级别)变化,代码都有可能无法通过编译。

string 的初始化方式

  • C 语言中,字符串的初始化方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用字符串字面量初始化:末尾自动追加 '\0',其余元素补 0
char str1[100] = "C++";

// 等价于上面 s1 的写法效果,显式写出 '\0',其余元素补 0
char str2[100] = {'C', '+', '+', '\0'};

// 不指定字符串长度,让编译器自动推导,实际大小是 4(包括末尾的 '\0')
char str3[] = "C++";

// 使用字符列表初始化(末尾必须要有 '\0',否则不是字符串)
char str4[] = {'C', '+', '+', '\0'};

// 部分初始化:其余元素补 0(非字符串语义,但结果满足 '\0' 结尾)
char str5[100] = {'C', '+', '+'};
  • C++ 中,字符串的初始化方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 默认初始化,s1 是空字符串,表示里面没有字符
string s1;

// 将指定的字符串内容拷贝到 s2 所代表的一段内存中,拷贝时不包括字符串末尾的 '\0'
string s2 = "I Love C++";

// 跟上面字符串 s2 的写法效果一样
string s3("I Love C++");

// 列表初始化
string s4{"I Love C++"};

// 将字符串 s2 的内容拷贝到 s4 所代表的一段内存中
string s5 = s2;

// 将 s5 初始化为连续 5 个字符 'a' 组成的字符串(aaaaa),可能会在系统内部创建临时对象
string s6(5, 'a');

// 从 s2 的下标 2(第 3 个字符)开始,连续拷贝到字符串末尾,构造新的字符串
string s7(s2, 2);

// 从 s2 的下标 2(第 3 个字符)开始,连续拷贝 4 个字符,构造新的字符串
string s8(s2, 2, 4);

// 通过字符数组(必须有一个元素是 '\0',否则会出现未定义行为)初始化字符串
char buf[] = {'a', 'b', 'c', '\0'};
string s9(buf);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 使用 const char* 初始化 string
const char* p1 = "Modern C++";
string s1 = p1;

// 跟上面字符串 s1 的写法效果一样
string s2(p1);

// 使用 char 数组(必须有一个元素是 '\0',否则会出现未定义行为)初始化字符串
char buf[] = {'a', 'b', 'c', '\0'};
string s3(buf);

// 使用 char*(必须有一个元素是 '\0',否则会出现未定义行为)初始化 string
char buf2[] = {'a', 'b', 'c', '\0'};
char* p2 = buf2;
string s4 = p2;

// 指定长度进行构造(不依赖 '\0')
char buf3[] = {'A', 'B', 'C', 'D', 'E'};
string s5(buf3, 3); // 只拷贝前 3 个字符:"ABC"

// 拷贝 char 数组的一部分(不依赖 '\0')
char buf4[] = "Hello C++ World";
string s6(buf4 + 6, 3); // 从第 7 个字符开始,拷贝 3 个字符:"C++"

// 拷贝 char 数组的一部分(依赖 '\0',否则会出现未定义行为)
char buf5[] = {'A', 'B', 'C', 'D', 'E', 'F', 'G', '\0'};
string s7(buf5 + 3); // 从第 4 个字符开始,一直拷贝,直到遇到 '\0',最终拷贝 4 个字符:DEFG
通过 char[] 或者 char * 初始化 string 的写法传入的真实类型是否依赖 '\0'说明
string(p)const char*依赖 C 风格字符串,必须包含 '\0',遇 '\0' 结束拷贝
string(p, n)const char* + size_t不依赖精确拷贝 n 个字节,p 可以没有 '\0'
string(arr)const char*(arr 衰变)依赖 arr 必须包含 '\0',遇 '\0' 结束拷贝
string(arr, n)const char* + size_t不依赖精确拷贝 n 个字节,arr 可以没有 '\0'

char 与 string 之间的转换

  • 在 C++ 中,char[]char* 可以隐式转换为 string,而 string 不能自动转换为 char[] 或者 char*,只能通过 string::c_str() 显式获取 const char *
  • string 的构造函数并不区分 char*char[],因为字符数组在参数传递时会退化为字符指针;是否依赖 '\0',只取决于开发者调用的是 string(const char*) 还是 string(const char*, size_t)

从 string 取得 char*

string 取得 char*,可以使用:

  • const char *c_str() const;,返回一个以 \0 结尾的字符串的首地址

特别注意

  • 在 C++ 中,char[]char * 可以隐式转换为 string 类型,反过来则不可以,例如右边这种写法是合法的: char *p = "abc"; string str = p;

string 拷贝到 char*

string 拷贝到 char* 指向的内存空间,可以使用:

  • int copy(char *s, int n, int pos=0) const;

将当前串中以 pos 位置开始的 n 个字符拷贝到以 s 为起始位置的字符数组中,返回实际拷贝的字符数量。特别注意,要保证指针 s 所指向的内存空间足以容纳当前的字符串,不然可能会发生越界。

从 string 与范围 for 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
string s10 = "hello c++";
// 通过范围 for 遍历字符串所有元素,使用常量引用
for (const char& c : s10) {
cout << c;
}
cout << endl;

// 通过范围 for 和引用遍历字符串所有元素,并更改其元素的值,使用普通引用
for (char& c : s10) {
c = toupper(c);
cout << c;
}
cout << endl;

程序运行输出的结果如下:

1
2
hello c++
HELLO C++

vector 容器

vector 的概念

vector 的数据存储以及操作方式,与 Array 非常相似,两者的唯一差别在于空间运用的灵活性。Array 是静态空间,一旦配置了就不能改变,要换大一点或者小一点的空间,可以,一切琐碎的细节得由自己来实现;首先配置一块新的空间,然后将旧空间的数据搬往新空间,再释放原来的空间。Vector 是动态空间,随着元素的加入,它的内部机制会自动扩充空间以容纳新元素。因此 vector 的运用对于内存的合理利用与运用的灵活性有很大的帮助,我们再也不必害怕空间不足而一开始就初始化一个大的 Array 了。Vector 的实现技术,关键在于其对大小的控制以及重新配置时的数据移动效率,一旦 vector 旧空间满了,如果客户每新增一个元素 vector 内部只是扩充一个元素的空间,实为不智,因为所谓的扩充空间(不论多大),一如刚才所说,是 “配置新空间 - 数据移动 - 释放旧空间” 的大工程,时间成本很高,应该加入某种未雨绸缪的考虑。

总结

  • vector 是将元素置于一个动态数组中加以管理的容器。
  • vector 可以随机存取元素(支持使用索引值直接存取, 用 [] 操作符或 at() 函数。
  • vector 尾部添加或移除元素非常快速,但是在头部或中部插入元素或移除元素比较耗时。

vector 的结构

vector 底层所采用的数据结构是线性连续空间(单向开口的连续内存空间),可以理解为支持动态开辟内存空间的数组(单向开口);它以两个迭代器(_Myfirst_Mylast)分别指向配置得来的连续空间中目前已被使用的范围,并以迭代器 _Myend 指向整块连续内存空间的尾端。vector 往尾部添加或移除元素的效率非常高,但是往头部或者中部插入元素或移除元素则比较耗时。为了降低空间配置时的速度成本,vector 实际配置的大小可能比客户端需求大一些,以应付将来可能的扩充,这里是容量的概念。换句话说,一个 vector 的容量永远大于或等于其大小,一旦容量等于大小,便是满载,下次再需要新增元素时,整个 vector 容器就得另觅居所(即扩容)。值得一提的是,所谓动态增加大小,并不是在原空间之后续接新空间(因为无法保证原空间之后尚有可配置的连续空间),而是申请一块更大的内存空间,然后将原数据拷贝到新空间,并释放原空间。因此,对 vector 的任何操作,一旦引起空间的重新配置,指向原 vector 的所有迭代器就都失效了,这是程序最容易出错的地方,务必小心。特别注意,vector 一旦需要执行扩容操作,那么每次都会以原来空间大小的 2 倍进行扩容。

vector 的初始化方式

  • 默认初始化
1
2
// 创建一个空 vector 对象
vector<int> v;
  • 指定大小(元素值默认初始化)
1
2
// 创建指定大小的 vector 对象
vector<int> v(5); // size = 5,int 全部为 0,结果是:[0, 0, 0, 0, 0]
  • 指定大小 + 指定初始值
1
2
// 创建指定大小的 vector 对象,并指定元素的初始值
vector<int> v(5, 10); // size = 5,int 全部为 10,结果是:[10, 10, 10, 10, 10]
  • 列表初始化
1
2
3
4
5
6
7
8
// 列表初始化,指定 vector 中的元素值
vector<int> v1 = {1, 2, 3};

// 跟上面 v1 的写法效果一样
vector<int> v2{1, 2, 3};

// 初始化一个空 vector 对象
vector<int> v{};
  • 拷贝初始化
1
2
3
4
5
6
7
vector<int> v1 = {1, 2, 3};

// v2 是 v1 的深拷贝
vector<int> v2(v1);

// 跟上面 v2 的写法效果一样
vector<int> v3 = v1;
  • 移动初始化
1
2
3
4
vector<int> v1 = {1, 2, 3};

// 移动初始化
vector<int> v2(move(v1)); // v1 被置为有效但未指定状态,v2 接管内存(高效)
  • 迭代器范围初始化
1
2
int arr[] = {1, 2, 3, 4};
vector<int> v(arr, arr + 4);
1
2
list<int> lst = {1, 2, 3};
vector<int> v(lst.begin(), lst.end());
  • 从 C 风格数组初始化
1
2
int arr[] = {1, 2, 3};
vector<int> v(begin(arr), end(arr));
  • C++ 17 / 20 的编译器自动推导类型
1
auto v = vector{1, 2, 3};

vector 容易混淆的初始化写法

  • vector 初始化时,使用括号与花括号,语义完全不同,比如:
  • vector<int> v(3, 2); 使用圆括号 () 初始化,调用的是 vector(size_type count, const T& value) 构造函数,表示创建一个包含 3 个元素 的 vector,并将每个元素都初始化为 2,因此最终结果为 [2, 2, 2]
  • vector<int> v{3, 2}; 使用花括号 {} 初始化,优先匹配 vector(initializer_list<T>) 构造函数,表示直接使用初始化列表中的元素进行构造,初始化列表中包含两个元素 3 和 2,因此最终结果为 [3, 2]

vector 与范围 for 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vector<string> v1 = {"a", "b", "c"};

// 通过范围 for 和引用遍历 vector 所有元素,使用常量引用
for (const string& item : v1) {
cout << item << " ";
}
cout << endl;

// 通过范围 for 和引用遍历 vector 所有元素,并更改其元素的值,使用普通引用
for (string& item : v1) {
item = item + "1";
cout << item << " ";
}
cout << endl;

程序运行输出的结果如下:

1
2
a b c 
a1 b1 c1

特别注意

  • 在 C++ 中使用范围 for 遍历 vector 时,不应在遍历过程中对容器执行插入或删除元素的操作,否则会导致未定义行为,可能表现为程序崩溃、数据异常或难以复现的内存错误。
  • 这是因为范围 for 遍历 vector 本质上依赖迭代器,而 vector 在插入或删除元素时可能发生内存重分配(比如扩容)或元素整体移动,导致迭代器失效;遍历过程中继续使用失效的迭代器会产生未定义行为,从而引发崩溃或各种不可预期的内存问题。
  • 不只是范围 for,任何使用迭代器遍历 vector 的场景,在对 vector 进行结构性修改(插入 / 删除)时都需要格外小心。

vector 与迭代器的使用

迭代器的概述

vector 提供了多种迭代器类型,用于以不同方式遍历和访问容器元素。其迭代器属于随机访问迭代器(Random Access Iterator),支持 ++--+n-n[] 下标访问等操作,效率与指针相当。

迭代器的使用注意事项

  • vector 的迭代器,本质上类似指针,内部通常就是原始指针或指针封装。
  • vector 插入、删除元素(尤其是触发扩容时)会导致迭代器失效。
  • 范围 for、STL 算法(如 std::find()std::sort())底层都依赖迭代器来实现。
  • 使用范围 for 遍历 vector 时,不应在遍历过程中对容器执行插入或删除元素的操作,否则会导致未定义行为,从而引发崩溃或各种不可预期的内存问题。
迭代器的类型

vector 容器的迭代器有四种类型:

  • iterator:普通正向迭代器
  • const_iterator:只读正向迭代器
  • reverse_iterator:普通反向迭代器(逆序)
  • const_reverse_iterator:只读反向迭代器(逆序)

  • vector<string>::iterator

    • 普通正向迭代器
    • 从第一个元素遍历到最后一个元素
    • 允许修改元素内容
      1
      2
      3
      4
      5
      vector<string> v = {"a", "b", "c"};

      for (vector<string>::iterator it = v.begin(); it != v.end(); ++it) {
      *it = "x"; // 可以修改元素值
      }
  • vector<string>::const_iterator

    • 只读正向迭代器
    • 不能通过迭代器修改元素的值
    • 常用于只读遍历,提升代码语义安全性
      1
      2
      3
      4
      5
      vector<string> v = {"a", "b", "c"};

      for (vector<string>::const_iterator it = v.cbegin(); it != v.cend(); ++it) {
      cout << *it << endl;
      }

  • vector<string>::reverse_iterator

    • 普通反向迭代器
    • 从最后一个元素遍历到第一个元素
    • 允许修改元素内容
    • 底层仍基于普通迭代器实现
      1
      2
      3
      4
      5
      vector<string> v = {"a", "b", "c"};

      for (vector<string>::reverse_iterator it = v.rbegin(); it != v.rend(); ++it) {
      *it = "x"; // 可以修改元素值
      }
  • vector<string>::const_reverse_iterator

    • 只读反向迭代器
    • 不能通过迭代器修改元素的值
    • 常用于反向只读访问
      1
      2
      3
      4
      5
      vector<string> v = {"a", "b", "c"};

      for (vector<string>::const_reverse_iterator it = v.crbegin(); it != v.crend(); ++it) {
      cout << *it << " ";
      }

迭代器的操作

vector 的迭代器属于随机访问迭代器(Random Access Iterator),支持 ++--+n-n[] 下标访问等操作,效率与指针相当。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vector<string> v1 = {"a", "b", "c", "d", "e"};
vector<string>::iterator it = v1.begin();

// 迭代器支持 * 操作
cout << *it << endl;

// 迭代器支持 [] 操作
cout << it[1] << endl;

// 迭代器支持 ++ 操作
++it;
cout << *it << endl;

// 迭代器支持 -- 操作
--it;
cout << *it << endl;

程序运行输出的结果如下:

1
2
3
4
a
b
b
a
迭代器的失效

迭代器失效是指:在使用容器迭代器的过程中,由于对容器进行了结构性修改(如调用容器的 erase()insert()push_back()resize()clear() 等成员函数),导致原本指向容器元素的迭代器不再指向有效元素或合法位置,此时继续使用该迭代器会产生未定义行为。因此,修改容器后必须确认迭代器是否仍然有效,必要时重新获取或使用返回的新迭代器。

  • 错误示例一:范围 for 中删除 vector 的元素
1
2
3
4
5
6
// 一边遍历 vector,一边删除元素
for (int x : v) {
if (x == 3) {
v.erase(v.begin()); // 错误写法:范围 for 内部迭代器失效,会导致未定义行为
}
}
  • 错误示例二:遍历 vector 时删除元素(迭代器失效)
1
2
3
4
5
6
7
8
vector<int> v1 = {1, 2, 3, 4, 5};

// 一边遍历 vector,一边删除元素
for (vector<int>::iterator it = v1.begin(); it != v1.end(); ++it) {
if (*it % 2 == 0) {
v1.erase(it); // 错误写法:erase() 调用后会导致当前位置及其后的迭代器全部失效,后续 ++it 操作的是失效迭代器,属于未定义行为
}
}
  • 错误示例三:遍历 vector 时插入元素(迭代器失效)
1
2
3
4
5
6
7
8
vector<int> v = {1, 2, 3};

// 一边遍历 vector,一边插入元素
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == 2) {
v.insert(it, 100); // 错误写法:可能触发扩容,导致迭代器 it 失效
}
}

  • 正确示例一:使用 erase() 的返回值继续遍历
1
2
3
4
5
6
7
8
9
10
vector<int> v1 = {1, 2, 3, 4, 5};

// 一边遍历 vector,一边删除元素
for (vector<int>::iterator it = v1.begin(); it != v1.end();) {
if (*it % 2 == 0) {
it = v1.erase(it); // 正确写法:使用 erase() 返回的新迭代器,erase() 返回指向被删除元素下一个位置的有效迭代器
} else {
++it;
}
}
  • 正确示例二:使用 erase() + remove_if(),避免遍历时删除
1
2
3
4
5
6
7
8
9
vector<int> v1 = {1, 2, 3, 4, 5};

// 正确写法:先用算法调整元素,再统一 erase(),避免遍历过程中修改容器
// remove_if() 算法负责把不需要的元素移动到容器末尾,并返回新的逻辑结尾;erase() 再把这段无效区间真正删除,这样可以避免在遍历过程中修改容器
v1.erase(
remove_if(v1.begin(), v1.end(),
[](int x) { return x % 2 == 0; }),
v1.end()
);
  • 正确示例三:使用 insert() 的返回值继续遍历
1
2
3
4
5
6
7
8
9
10
11
vector<int> v1 = {1, 2, 3};

// 一边遍历 vector,一边插入元素
for (auto it = v1.begin(); it != v1.end();) {
if (*it == 2) {
it = v1.insert(it, 100); // 正确写法:使用 insert() 返回的新迭代器
it += 2; // 跳过原元素和新插入的元素
} else {
++it;
}
}
  • 正确示例四:不在遍历时插入
1
2
3
4
5
6
7
8
9
10
11
vector<int> v = {1, 2, 3};
vector<int> result;

for (int x : v) {
if (x == 2) {
result.push_back(100);
}
result.push_back(x);
}

v.swap(result);

总结

  • C++ 中迭代器失效是指在容器发生结构性修改后,原有迭代器不再指向有效元素,继续使用失效的迭代器会导致未定义行为,正确做法是使用 erase()insert() 的返回值或避免在遍历过程中修改(插入 / 删除)容器。
  • 由于范围 for 遍历 vector 本质上依赖迭代器,因此使用范围 for 遍历 vector 时,不应在遍历过程中对容器执行插入或删除元素的操作,否则会导致未定义行为,从而引发崩溃或各种不可预期的内存问题。
容器类型内存结构insert() 导致的迭代器失效erase() 导致的迭代器失效推荐写法说明
vector 连续内存⚠️ 发生扩容时:所有迭代器、指针、引用全部失效
⚠️ 未扩容时:插入点及其后的迭代器失效
⚠️ 被删位置及其后的迭代器全部失效it = v.erase(it)
erase() + remove_if()
连续内存,插入 / 删除需要移动元素,是迭代器最容易失效的容器。
deque 分段连续⚠️ 可能导致所有迭代器失效
⚠️ C++ 标准未保证稳定性,尤其是中间插入
⚠️ 可能导致所有迭代器失效尽量只在头尾两端操作分段连续结构,迭代器稳定性差,不要依赖 deque 的迭代器长期有效。
list 双向链表✅ 插入不使任何已有迭代器失效✅ 仅被删除元素的迭代器失效list::insert()
list::remove_if()
节点独立,插入删除只改指针,迭代器最稳定。
map / set 红黑树✅ 插入不使任何已有迭代器失效✅ 仅被删除元素的迭代器失效it = m.erase(it)树结构保持节点稳定,删除时必须接收返回迭代器继续遍历。