C++ string 类复习笔记
一、为什么学习 string 类
1.1 C 语言字符串的痛点
C 语言字符串本质是 '\0' 结尾的字符数组,依赖 strcpy/strlen/strcmp 等库函数操作,存在三个核心问题:
- 不符合 OOP 思想:函数与数据分离
- 内存自行管理:malloc/free 容易遗忘或越界
- 越界访问风险:没有边界检查
实际开发中,几乎所有人都用
std::string,很少再用 C 库字符串函数。
二、C++11 语法补充
2.1 auto 关键字 ⭐ 重点
核心原则:
auto声明变量时,编译器必须在编译期推导出类型 → 必须初始化auto+ 指针:auto和auto*没有区别auto+ 引用:必须加&(auto&)- 同一行声明多个变量必须类型一致
不能用 auto 的场景:
- 函数参数(
void func(auto a)❌) - 直接声明数组(
auto arr[] = {1,2,3}❌)
auto 可以做返回值,但建议谨慎使用。
2.2 范围 for ⭐ 重点
cpp
for (auto e : array) // e 是拷贝,修改不影响原值
for (auto& e : array) // e 是引用,可以修改原值
适用范围: 数组、所有 STL 容器。底层是迭代器实现,汇编层可见。
三、string 类常用接口
3.1 构造 ⭐
| 函数 | 说明 |
|---|---|
string() |
空字符串 |
string(const char* s) |
C 字符串构造 |
string(const string& s) |
拷贝构造 |
string(size_t n, char c) |
n 个字符 c |
3.2 容量操作 ⭐ 重点
| 方法 | 说明 |
|---|---|
size() / length() |
返回有效字符长度(推荐用 size(),与容器统一) |
capacity() |
返回总容量 |
empty() |
判空,返回 true 表示空串 |
clear() |
清空字符,不释放底层空间 |
reserve(n) |
预留空间,不改变有效元素个数;n < capacity 时不做任何事 |
resize(n, c) |
改变有效字符数为 n,多了填 c(不填则填 \0),少了截断 |
reserve vs resize 区别: reserve 只改容量不改内容;resize 改内容数量,可能同时改容量。
3.3 访问与遍历
| 方式 | 说明 |
|---|---|
operator[] |
下标访问(不检查越界,实际中最常用) |
begin() / end() |
正向迭代器 |
rbegin() / rend() |
反向迭代器 |
| 范围 for | C++11 最简洁方式 |
3.4 修改操作 ⭐ 重点
| 方法 | 说明 |
|---|---|
push_back(c) |
尾插单个字符 |
append(s) |
追加字符串 |
operator+= |
最常用,可追加字符/字符串 |
c_str() |
返回 C 格式字符串(const char*) |
find(c, pos) |
从 pos 开始往后 找,返回位置,找不到返回 string::npos |
rfind(c, pos) |
从 pos 开始往前找 |
substr(pos, n) |
从 pos 截取 n 个字符返回子串 |
常用模式: 预估数据量后先
reserve预留空间,减少扩容开销。
3.5 非成员函数
| 函数 | 说明 |
|---|---|
operator>> / operator<< |
输入输出重载 |
getline(cin, str) |
读取整行(cin >> 遇到空格会停) |
operator+ |
尽量少用,传值返回导致深拷贝 |
>, <, == 等 |
支持大小比较(按字典序) |
四、VS 与 g++ 底层结构差异
| 平台 | 大小 | 核心设计 |
|---|---|---|
| VS (MSVC) | 28 字节 | 长度 < 16 时用内部数组(SSO 小字符串优化),>= 16 时堆分配 |
| g++ (libstdc++) | 4 字节 | 写时拷贝(COW),对象只存一个指针指向堆空间(含长度+容量+引用计数) |
SSO(Small String Optimization): 短字符串直接存在对象内部,避免堆分配,效率高。这是现代 string 实现的主流方案。
五、经典 OJ 练习 ⭐ 完整代码与思路
5.1 仅反转字母(reverseOnlyLetters)
题目: 给定一个字符串 S,返回反转后的字符串,其中非字母的字符保留在原位置,字母反转。
思路: 双指针 begin/end,各自跳过非字母字符后交换,O(n) 时间。
cpp
class Solution {
public:
bool isLetter(char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
}
string reverseOnlyLetters(string S) {
if (S.empty()) return S;
size_t begin = 0, end = S.size() - 1;
while (begin < end) {
while (begin < end && !isLetter(S[begin])) ++begin; // 跳过非字母
while (begin < end && !isLetter(S[end])) --end; // 跳过非字母
swap(S[begin], S[end]);
++begin;
--end;
}
return S;
}
};
5.2 第一个只出现一次的字符(firstUniqChar)
题目: 在字符串 s 中找出第一个不重复的字符,返回其索引,不存在则返回 -1。
思路: 第一遍遍历用 int256 统计频率,第二遍按原顺序找 count==1 的字符,O(n)。
cpp
class Solution {
public:
int firstUniqChar(string s) {
int count[256] = {0};
for (int i = 0; i < s.size(); ++i)
count[s[i]]++; // 统计每个字符出现次数
for (int i = 0; i < s.size(); ++i)
if (count[s[i]] == 1) // 按原顺序找第一个 count==1
return i;
return -1;
}
};
5.3 最后一个单词的长度
题目: 输入一行字符串,输出最后一个单词的长度。
关键陷阱: 必须用 getline 而非 cin >>,因为 cin >> 遇到空格就结束读取。
cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string line;
while (getline(cin, line)) { // 必须用 getline,不能用 cin>>
size_t pos = line.rfind(' '); // 找到最后一个空格
cout << line.size() - pos - 1 << endl; // 最后一个单词长度
}
return 0;
}
5.4 验证回文串(isPalindrome)
题目: 验证字符串是否是回文,只考虑字母和数字字符,忽略大小写。
思路: 统一转大写/小写 → 双指针跳过非字母数字字符 → 比较是否相等。
cpp
class Solution {
public:
bool isLetterOrNumber(char ch) {
return (ch >= '0' && ch <= '9')
|| (ch >= 'a' && ch <= 'z')
|| (ch >= 'A' && ch <= 'Z');
}
bool isPalindrome(string s) {
// 统一转大写
for (auto& ch : s)
if (ch >= 'a' && ch <= 'z')
ch -= 32;
int begin = 0, end = s.size() - 1;
while (begin < end) {
while (begin < end && !isLetterOrNumber(s[begin])) ++begin;
while (begin < end && !isLetterOrNumber(s[end])) --end;
if (s[begin] != s[end])
return false;
++begin;
--end;
}
return true;
}
};
5.5 字符串相加(addStrings)
题目: 给定两个字符串形式的非负整数 num1 和 num2,计算它们的和,不能使用内置大整数库。
思路: 从末位向前模拟竖式加法,进位变量 next,结果尾插后 reverse。注意字符转数字 - '0'。
cpp
class Solution {
public:
string addStrings(string num1, string num2) {
int end1 = num1.size() - 1, end2 = num2.size() - 1;
int next = 0; // 进位
string addret; // 存储结果
while (end1 >= 0 || end2 >= 0) {
int v1 = end1 >= 0 ? num1[end1--] - '0' : 0; // 字符转数字
int v2 = end2 >= 0 ? num2[end2--] - '0' : 0;
int sum = v1 + v2 + next;
next = sum > 9 ? 1 : 0; // 判断是否有进位
addret += (sum % 10 + '0'); // 当前位结果转字符尾插
}
if (next == 1)
addret += '1'; // 最后的进位
reverse(addret.begin(), addret.end()); // 反转得到正确顺序
return addret;
}
};
注意: 注释掉的
addret.insert(addret.begin(), ...)是头插写法,每次头插 O(n),效率低。尾插 + reverse 是 O(n),更优。
5.6 字符串相乘(课后练习,思路提示)
大数乘法,核心是创建一个长度为 len1 + len2 的结果数组,逐位相乘累加,最后处理进位和转字符串。
5.7 翻转字符串中的单词(课后练习,思路提示)
整体 reverse → 每个单词再 reverse。或双指针定位每个单词边界来翻转。
六、string 类的模拟实现 ⭐ 面试重中之重
6.1 浅拷贝 vs 深拷贝(经典面试题)
问题: 下面的代码有什么问题?
cpp
class String {
public:
String(const char* str = "")
: _str(new char[strlen(str) + 1])
{ strcpy(_str, str); }
~String() { delete[] _str; }
private:
char* _str;
};
void test() {
String s1("hello");
String s2(s1); // s1 和 s2 的 _str 指向同一块内存
} // s2 先析构 delete 掉内存,s1 析构时再次 delete → 崩溃!
答案: 编译器默认生成的拷贝构造是逐字节拷贝(浅拷贝/位拷贝) ,导致 s1 和 s2 的 _str 指向同一块堆内存。出了作用域后 s2 先析构释放该内存,s1 析构时再次 delete 同一块内存 → double free 导致程序崩溃。
解决方案------深拷贝: 每个对象分配自己的独立内存空间。
6.2 传统写法(含完整解析)
cpp
class String {
public:
// 构造函数
String(const char* str = "") {
if (nullptr == str) {
assert(false); // 不允许传 nullptr
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造函数(深拷贝)
String(const String& s)
: _str(new char[strlen(s._str) + 1])
{
strcpy(_str, s._str);
}
// 赋值运算符重载(深拷贝)
String& operator=(const String& s) {
if (this != &s) { // 1. 防止自赋值
char* pStr = new char[strlen(s._str) + 1]; // 2. 先开新空间
strcpy(pStr, s._str); // 先拷贝数据
delete[] _str; // 3. 再释放旧空间
_str = pStr; // 4. 接管新空间
}
return *this;
}
// 析构函数
~String() {
if (_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
关键面试问题:operator= 中为什么要先 new 再 delete?
答案: 如果先 delete 再 new,一旦 new 抛异常(
std::bad_alloc),旧数据已被销毁无法恢复。先 new 成功后再 delete,保证了异常安全。另一个好处是:处理自赋值(
s = s)时,如果先 delete 再 new,会把自身的数据一起销毁,new 出来的就是垃圾数据了。
6.3 现代写法(推荐,swap 技巧)
核心思想: 利用 swap + 临时对象自动析构来管理资源(RAII 思想的延伸)。
cpp
class String {
public:
String(const char* str = "") {
if (nullptr == str) {
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造 ------ 现代写法
String(const String& s)
: _str(nullptr) // 必须初始化为 nullptr!
{
String tmp(s._str); // 调用构造函数,tmp 持有独立副本
swap(_str, tmp._str); // 交换指针,现在 _str 指向独立内存
} // tmp 析构,带走 nullptr(安全)
// 赋值 ------ 现代写法(最简洁版本)
String& operator=(String s) { // 传值!s 已经是实参的拷贝
swap(_str, s._str); // 交换指针
return *this;
} // s 析构,带走旧资源
~String() {
if (_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
面试要点:
- 拷贝构造中
_str(nullptr)是必须的,否则 swap 后 tmp 析构时会 delete 一个随机的野指针。- 赋值用传值而非传引用,利用了"传参即拷贝"的特性,省去自己创建临时对象。
- 现代写法代码量更少、更安全,是 C++ 惯用法(copy-and-swap idiom)。
传统 vs 现代对比:
| 维度 | 传统写法 | 现代写法 |
|---|---|---|
| 代码量 | 多 | 少 |
| 异常安全 | 需要手动保证 | 天然异常安全 |
| 自赋值处理 | 显式 if 判断 | 传值自然处理 |
| 可读性 | 直观 | 需要理解 swap 语义 |
6.4 写时拷贝 COW(Copy-On-Write)
本质: 浅拷贝 + 引用计数。
工作流程:
- 构造时引用计数 = 1
- 拷贝构造时引用计数 +1,不真正拷贝数据(浅拷贝)
- 读取操作:直接共享,无额外开销
- 写入操作:检查引用计数,> 1 则先深拷贝一份再修改("写时拷贝")
- 析构时引用计数 -1,计数归零才真正释放内存
缺点: C++11 后被废弃,原因是多线程环境下引用计数的原子操作开销大,甚至超过了直接深拷贝。
七、核心问答 30 题(面试自查)
构造与析构
Q1:string 有哪几种常用的构造方式?
A:空串构造 string()、C串构造 string(const char*)、拷贝构造 string(const string&)、n个字符构造 string(n, c)。
Q2:string s("hello") 和 string s = "hello" 有什么区别?
A:前者是直接初始化(调用构造函数),后者是拷贝初始化(C++17 前可能先构造临时对象再拷贝,C++17 后强制省略拷贝,两者等价)。
容量相关
Q3:size() 和 capacity() 的区别是什么?
A:size() 返回有效字符个数(字符串长度),capacity() 返回底层分配的总容量。比如 string("hello") 的 size=5,capacity 可能为 15(预留空间)。
Q4:clear() 会释放内存吗?
A:不会。clear() 只将 size 置 0,capacity 保持不变,底层空间不释放。
Q5:reserve(n) 做了什么?n < capacity 时会发生什么?
A:reserve 为 string 预留至少 n 字节空间。当 n < capacity 时,reserve 不做任何事(不会缩小容量)。
Q6:resize(n) 和 reserve(n) 的区别?
A:resize 改变的是有效字符个数(size),多出的填 \0,少了截断,可能引起 capacity 变化。reserve 只改变容量(capacity),不改变 size,不初始化多出的空间。
Q7:resize(n, c) 和 resize(n) 的区别?
A:resize(n) 多出的空间用 \0 填充,resize(n, c) 用字符 c 填充。当缩小时两者无区别。
Q8:empty() 和 size()==0 有区别吗?
A:没有本质区别,empty() 通常直接返回 size() == 0,用 empty() 更语义化。
访问与遍历
Q9:operator\[\] 和 at() 有什么区别?
A:operator[] 不检查越界(UB),at() 越界会抛 std::out_of_range 异常。实际开发中 [] 更常用。
Q10:string 有哪几种遍历方式?
A:4 种------下标 for i=0..size()、迭代器 begin()/end()、范围 for for(auto ch : s)、反向迭代器 rbegin()/rend()。
修改操作
Q11:push_back、append、operator+= 三者的区别和使用场景?
A:push_back 只能追加单个字符;append 追加字符串;operator+= 最通用,可追加字符/字符串/另一个 string。实际中最推荐 operator+=。
Q12:c_str() 返回什么?有什么注意事项?
A:返回 const char*,指向 string 内部的 C 风格字符串(以 \0 结尾)。注意返回的指针在 string 对象修改后可能失效(扩容后指向被释放的旧内存)。
Q13:find() 找不到时返回什么?
A:返回 string::npos,这是一个 static const 的 size_t 最大值(通常是 -1 转无符号数)。
Q14:find() 和 rfind() 的区别?
A:find 从 pos 位置往后找(正向),rfind 从 pos 位置往前找(反向)。都返回找到的位置索引。
Q15:substr(pos, n) 如果 n 超出范围怎么办?
A:截取到字符串末尾就停止,不会越界。实际返回从 pos 到字符串末尾的子串。
Q16:如何高效地往 string 后面拼接大量字符串?
A:先 reserve 预留足够空间,再用 operator+= 追加,避免多次扩容带来的内存重分配和拷贝开销。
输入输出
Q17:cin >> s 和 getline(cin, s) 的区别?
A:cin >> 以空白字符(空格、Tab、换行)为分隔符,遇到就停止。getline 读取整行,遇到换行符才停止(换行符被丢弃不存入字符串)。
Q18:operator+ 为什么建议少用?
A:operator+ 返回的是临时对象(传值返回),每次调用都会产生一次深拷贝。多次拼接如 s1 + s2 + s3 会产生多个临时对象,效率低。
字符串与数字转换
Q19:字符 '5' 如何转为数字 5?
A:ch - '0'。ASCII 码中字符 '0'-'9' 是连续的,'5' - '0' = 53 - 48 = 5。
Q20:数字 5 如何转为字符 '5'?
A:5 + '0' 或 5 + 48。推荐 '0' 写法,更直观。
底层与模拟实现
Q21:什么是浅拷贝?有什么问题?
A:浅拷贝是逐字节拷贝(memcpy 语义),对于只含值类型的类没问题,但对于包含指针指向堆内存的类,会导致多个对象的指针指向同一块堆内存。析构时同一块内存被多次 delete,造成 double free 崩溃。
Q22:什么类必须显式定义拷贝构造和赋值运算符?
A:涉及资源管理(如动态内存、文件句柄、锁等)的类。通俗说就是"类中有指针指向堆空间"的情况。
Q23:operator= 中如何处理自赋值?
A:传统写法用 if (this != &s) 判断。现代写法(传值 swap)天然安全,不需要显式判断。
Q24:operator= 中为什么先 new 再 delete?
A:保证异常安全。如果先 delete 再 new,new 抛异常时旧数据已经没了,对象处于损坏状态。先 new 成功后再 delete,即便 new 失败旧数据仍然完好。
Q25:拷贝构造中 _str(nullptr) 为什么在现代写法中是必须的?
A:如果不初始化为 nullptr,_str 是一个随机野指针。swap 后临时对象持有这个野指针,析构时会 delete 野指针 → 未定义行为。
Q26:现代写法中的赋值运算符为什么用传值而非传引用?
A:传值调用会自动调用拷贝构造函数创建形参 s,s 已经是实参的副本,直接 swap 即可。这省去了手动创建临时对象的代码,且自然处理了自赋值和异常安全。
Q27:什么是 SSO(小字符串优化)?
A:Small String Optimization。当字符串很短(如 VS 中 < 16 字节)时,直接存在 string 对象内部的固定数组中,不需要堆分配。这是现代 string 实现的主流方案。
Q28:什么是写时拷贝(COW)?为什么 C++11 后被废弃?
A:COW 使用浅拷贝 + 引用计数,写入时才真正拷贝。C++11 后废弃的原因:多线程环境下引用计数需要原子操作,其开销超过了直接深拷贝,而且 COW 与 operator[] 的语义存在矛盾(读操作也可能触发写)。
算法题思路
Q29:查找第一个不重复字符的最优思路?
A:遍历一遍用 int256(或 int26 如果只有字母)统计频率 → 再遍历一遍找第一个 count==1 的字符。时间 O(n),空间 O(1)。
Q30:字符串相加为什么不头插而用尾插+reverse?
A:头插 insert(begin(), c) 每次都要移动已插入的所有字符,总时间 O(n²)。尾插 O(1) 每次,最后 reverse O(n),总时间 O(n)。
八、复习重点分级
🔴 第一优先级(面试高频 + 实际常用)
- 浅拷贝/深拷贝问题:原因(共享同一块内存 → double free)、后果(崩溃)、解决方案(深拷贝)
- string 模拟实现:传统写法 + 现代写法都要能手写(构造/拷贝构造/赋值/析构)
- operator= 为什么先 new 再 delete(异常安全)
- OJ 题完整代码:字符串相加、验证回文、第一个只出现一次的字符
🟡 第二优先级(常用但偏应用)
size()vscapacity()区别reserve()vsresize()区别find()/rfind()/substr()用法c_str()返回什么getlinevscin >>
🟢 第三优先级(知道就行)
auto使用规则(不能做参数、不能声明数组)- 范围 for 底层是迭代器
- VS 16 字节 SSO 优化 vs g++ 写时拷贝
operator+少用(传值返回深拷贝)
九、速记口诀
- 构造五兄弟:空、C串、拷贝、n个c
- 容量三兄弟:size(有效)、capacity(容量)、reserve(预留)
- 修改五兄弟:push_back、append、+=、find、substr
- 深拷贝三件套:拷贝构造 + 赋值重载 + 析构(类涉及资源管理必须显式定义)
- 现代写法核心:swap 大法,传值即拷贝,临时对象自动析构
- 头插不如尾插+reverse:头插 O(n²),尾插+reverse O(n)