
🎬 个人主页 :道尔柯南
❄专栏传送门 :《C语言》《C++》《Linux操作系统》
昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。
文章目录
-
- 一、前言
- [二、为什么需要 string 类?](#二、为什么需要 string 类?)
-
- [2.1 C 语言字符串的特点](#2.1 C 语言字符串的特点)
- [2.2 C++ string 类的优势](#2.2 C++ string 类的优势)
- [三、string 类的基本使用](#三、string 类的基本使用)
-
- [3.1 头文件](#3.1 头文件)
- [3.2 string 对象的构造](#3.2 string 对象的构造)
- [四、C++11 新特性:auto 与范围 for](#四、C++11 新特性:auto 与范围 for)
-
- [4.1 auto:让编译器推导类型](#4.1 auto:让编译器推导类型)
- [4.2 auto 与引用](#4.2 auto 与引用)
- [4.3 范围 for:简化遍历](#4.3 范围 for:简化遍历)
- [五、string 的容量操作](#五、string 的容量操作)
-
- [5.1 size() 与 length()](#5.1 size() 与 length())
- [5.2 capacity():查看容量](#5.2 capacity():查看容量)
- [5.3 empty():判断字符串是否为空](#5.3 empty():判断字符串是否为空)
- [5.4 clear():清空字符串](#5.4 clear():清空字符串)
- [5.5 resize():调整有效字符数量](#5.5 resize():调整有效字符数量)
- [5.6 reserve():预留容量](#5.6 reserve():预留容量)
- [六、string 的访问与遍历](#六、string 的访问与遍历)
-
- [6.1 使用下标访问字符](#6.1 使用下标访问字符)
- [6.2 使用迭代器遍历](#6.2 使用迭代器遍历)
- [6.3 使用范围 for 遍历](#6.3 使用范围 for 遍历)
- [七、string 的修改、查找与截取](#七、string 的修改、查找与截取)
-
- [7.1 push_back():尾部追加字符](#7.1 push_back():尾部追加字符)
- [7.2 append():追加字符串](#7.2 append():追加字符串)
- [7.3 operator+=:推荐掌握的拼接方式](#7.3 operator+=:推荐掌握的拼接方式)
- [7.4 c_str():获取 C 风格字符串](#7.4 c_str():获取 C 风格字符串)
- [7.5 find():从前向后查找](#7.5 find():从前向后查找)
- [7.6 rfind():从后向前查找](#7.6 rfind():从后向前查找)
- [7.7 substr():截取子字符串](#7.7 substr():截取子字符串)
- [八、string 的非成员函数与比较操作](#八、string 的非成员函数与比较操作)
-
- [8.1 使用 + 拼接字符串](#8.1 使用 + 拼接字符串)
- [8.2 使用 cin 和 cout 输入输出](#8.2 使用 cin 和 cout 输入输出)
- [8.3 使用 getline() 读取整行](#8.3 使用 getline() 读取整行)
- [8.4 使用比较运算符](#8.4 使用比较运算符)
- [九、string 结构与内存管理](#九、string 结构与内存管理)
-
- [9.1 为什么 string 需要管理内存?](#9.1 为什么 string 需要管理内存?)
- [9.2 小字符串优化](#9.2 小字符串优化)
- [9.3 如何理解容量和有效长度?](#9.3 如何理解容量和有效长度?)
- 十、经典面试问题:浅拷贝与深拷贝
-
- [10.1 一个存在问题的 String 类](#10.1 一个存在问题的 String 类)
- [10.2 什么是浅拷贝?](#10.2 什么是浅拷贝?)
- [10.3 什么是深拷贝?](#10.3 什么是深拷贝?)
- [10.4 深拷贝的 String 实现](#10.4 深拷贝的 String 实现)
- [10.5 为什么赋值运算符要返回引用?](#10.5 为什么赋值运算符要返回引用?)
- [10.6 现代写法:拷贝与交换](#10.6 现代写法:拷贝与交换)
- 十一、写时拷贝是什么?
- [十二、经典 string 算法练习](#十二、经典 string 算法练习)
-
- [12.1 仅反转字母](#12.1 仅反转字母)
- [12.2 找出字符串中第一个只出现一次的字符](#12.2 找出字符串中第一个只出现一次的字符)
- [12.3 判断回文字符串](#12.3 判断回文字符串)
- [12.4 字符串相加](#12.4 字符串相加)
- [十三、string 学习路线总结](#十三、string 学习路线总结)
- 十四、总结
- 结尾
一、前言
在 C++ 编程中,字符串是最常见的数据类型之一。无论是处理用户输入、读取文件、解析数据,还是参加算法竞赛,我们都离不开字符串。
在 C 语言中,我们通常使用字符数组来表示字符串,例如:
char str[] = "hello";
虽然这种方式简单,但在实际使用中,需要注意字符串结束标志 '\0'、数组容量以及内存管理等问题。
为了解决这些问题,C++ 标准库提供了 string 类。通过 string,我们可以更方便地创建、修改、查找和比较字符串,而不必手动管理每一个字符数组的细节。
本文将结合 C++ 初学者的学习路线,系统介绍 string 类的常用操作,并进一步分析字符串算法与自定义字符串类中的深拷贝问题。
本文学习目标:
- 理解为什么需要
string类。 - 掌握
string的构造、容量、访问和修改操作。 - 学会使用
auto和范围for简化代码。 - 掌握字符串查找、截取、比较等常见操作。
- 通过算法练习理解双指针和字符统计思想。
- 理解浅拷贝、深拷贝以及资源管理的基本原理。
二、为什么需要 string 类?
2.1 C 语言字符串的特点
在 C 语言中,字符串通常是以 '\0' 结尾的字符序列。
例如:
char str[] = "hello";
它在内存中的逻辑结构可以表示为:
'h' 'e' 'l' 'l' 'o' '\0'
字符串中的有效字符有 5 个,但数组还需要额外的空间存储结束标志 '\0'。
C 语言提供了一系列字符串处理函数,例如:
strlen():计算字符串长度。strcpy():复制字符串。strcat():拼接字符串。strcmp():比较字符串。
使用这些函数时,程序员需要自行保证目标空间足够、字符串正确结束,以及内存访问合法。
例如:
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
char str1[] = "hello";
char str2[20];
strcpy(str2, str1);
cout << str2 << endl;
return 0;
}
这段代码可以正常工作,但如果目标数组空间不足,复制操作就可能引发越界访问。
此外,C 语言字符串函数与字符数组是分开管理的,代码写得不够谨慎时容易出现内存安全问题。
2.2 C++ string 类的优势
C++ 提供了 std::string,它能够封装字符串数据,并提供一系列方便的成员函数。
例如:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s = "hello";
s += " world";
cout << s << endl;
cout << s.size() << endl;
return 0;
}
输出:
hello world
11
与传统字符数组相比,string 具有以下优点:
- 使用方便: 可以直接通过
+或+=拼接字符串。 - 管理长度: 可以使用
size()获取有效字符的数量。 - 方便修改: 可以通过下标、成员函数等方式修改内容。
- 支持比较: 可以直接使用比较运算符比较字符串。
- 便于配合 STL: 可以使用迭代器、算法以及范围
for等功能。
因此,在日常 C++ 开发和算法题中,通常优先使用 string 处理字符串。
三、string 类的基本使用
3.1 头文件
使用 string 类,需要包含对应的头文件:
#include <string>
如果程序还需要输入输出,则通常还要包含:
#include <iostream>
完整示例:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s = "hello";
cout << s << endl;
return 0;
}
需要注意,string 的完整名称是 std::string。如果没有使用 using namespace std;,就应写成 std::string。
3.2 string 对象的构造
介绍几种常用构造方式。
| 写法 | 含义 |
|---|---|
string s1; |
创建空字符串 |
string s2("hello"); |
使用 C 风格字符串构造 |
string s3(5, 'a'); |
创建由 5 个字符 a 组成的字符串 |
string s4(s2); |
通过已有字符串复制构造 |
示例:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1;
string s2("hello");
string s3(5, 'a');
string s4(s2);
cout << "s1: " << s1 << endl;
cout << "s2: " << s2 << endl;
cout << "s3: " << s3 << endl;
cout << "s4: " << s4 << endl;
return 0;
}
输出:
s1:
s2: hello
s3: aaaaa
s4: hello
初学者需要重点掌握空字符串构造、C 风格字符串构造以及拷贝构造。
四、C++11 新特性:auto 与范围 for
为了更方便地遍历字符串和容器,C++11 引入了 auto 类型推导和范围 for 循环。
这两个特性在现代 C++ 代码中非常常见。
4.1 auto:让编译器推导类型
auto 可以让编译器根据变量的初始化表达式推导变量类型。
例如:
int a = 10;
auto b = a;
char c = 'A';
auto d = c;
这里,编译器会推导出:
b的类型为int。d的类型为char。
使用 auto 时,变量必须能够从初始化表达式中推导出类型。
错误示例:
auto x; // 错误:没有初始化表达式,无法推导类型
auto 还可以用于迭代器。
例如:
#include <iostream>
#include <map>
#include <string>
using namespace std;
int main()
{
map<string, string> dict = {
{"apple", "苹果"},
{"orange", "橙子"},
{"pear", "梨"}
};
auto it = dict.begin();
while (it != dict.end())
{
cout << it->first << ":"
<< it->second << endl;
++it;
}
return 0;
}
如果不使用 auto,就需要写出较长的迭代器类型。
因此,auto 可以让代码更加简洁,特别适合类型名称复杂的场景。
4.2 auto 与引用
当我们需要修改原始变量时,需要注意 auto 与 auto& 的区别。
int x = 10;
auto a = x;
auto& b = x;
a = 20;
b = 30;
最终:
x = 30
a = 20
b 引用 x
原因在于:
auto a = x;创建了一个独立变量。auto& b = x;创建了一个引用,b与x指向同一个变量。
在字符串遍历中,如果需要修改原字符串,就要特别注意是否使用引用。
4.3 范围 for:简化遍历
传统遍历字符串通常需要下标:
string s = "hello";
for (size_t i = 0; i < s.size(); ++i)
{
cout << s[i] << " ";
}
使用范围 for 后,可以写成:
string s = "hello";
for (auto ch : s)
{
cout << ch << " ";
}
输出:
h e l l o
范围 for 会自动遍历集合中的元素,不需要我们手动维护下标。
如果想修改字符串中的字符,则应使用引用:
string s = "hello";
for (auto& ch : s)
{
if (ch >= 'a' && ch <= 'z')
{
ch = ch - 'a' + 'A';
}
}
cout << s << endl;
输出:
HELLO
这里的 auto& ch 让 ch 引用字符串中的实际字符,因此修改 ch 就会改变原字符串。
如果写成 auto ch,循环变量只是字符副本,修改它不会改变原字符串。
五、string 的容量操作
字符串不仅有内容,还有长度和容量等概念。
这是学习 string 时非常重要的一部分。
5.1 size() 与 length()
这两个成员函数都用于获取字符串的有效字符数量。
string s = "hello";
cout << s.size() << endl;
cout << s.length() << endl;
输出:
5
5
在标准 std::string 中,size() 和 length() 的含义相同。
一般情况下,建议优先使用 size(),因为它与 STL 中许多容器的接口风格保持一致。
5.2 capacity():查看容量
capacity() 用于获取当前字符串在不重新分配存储空间的情况下,能够容纳的字符数量。
string s = "hello";
cout << "size: " << s.size() << endl;
cout << "capacity: " << s.capacity() << endl;
这里的 size() 通常为 5,但 capacity() 的具体值取决于标准库实现,不能假定一定等于 5。
可以这样理解:
size():当前实际保存了多少个有效字符。capacity():当前分配的存储空间最多能够容纳多少个字符。
5.3 empty():判断字符串是否为空
empty() 用于判断字符串当前是否不包含任何字符。
string s;
if (s.empty())
{
cout << "字符串为空" << endl;
}
与直接判断 s.size() == 0 相比,empty() 的表达更加直观。
5.4 clear():清空字符串
clear() 用于清除字符串的有效内容。
string s = "hello";
s.clear();
cout << s.size() << endl;
cout << s.empty() << endl;
输出:
0
1
需要注意:
clear() 清除的是字符串的有效字符,不保证释放已经分配的容量。
因此,清空字符串后,size() 会变为 0,但 capacity() 不一定变为 0。
5.5 resize():调整有效字符数量
resize() 用于调整字符串的有效字符数量。
如果新长度小于原长度,多余的字符会被删除;如果新长度大于原长度,则会在末尾补充字符。
示例:
string s = "hello";
s.resize(3);
cout << s << endl;
s.resize(5, 'x');
cout << s << endl;
输出:
hel
helxx
resize(n, c) 在需要增加字符时,会使用指定字符 c 进行填充。
而 resize(n) 在增加有效字符数量时,会使用值为 char{} 的字符填充,通常表现为 '\0'。
需要注意,resize() 可能引起容量变化,尤其是在字符串需要容纳更多字符时。
5.6 reserve():预留容量
reserve() 用于请求字符串预留一定的容量,但不会直接改变字符串的有效字符数量。
例如:
string s;
s.reserve(100);
cout << s.size() << endl;
cout << s.capacity() << endl;
此时 size() 仍然为 0,而容量至少能够满足所请求的大小;具体容量可能更大。
如果我们预先知道字符串需要保存大量字符,可以考虑使用 reserve(),减少追加字符时可能发生的重复内存分配。
需要注意,reserve() 是容量管理接口,不是修改字符串有效长度的接口。
六、string 的访问与遍历
6.1 使用下标访问字符
operator[] 可以根据位置访问字符串中的字符。
string s = "hello";
cout << s[0] << endl;
cout << s[1] << endl;
输出:
h
e
字符串下标从 0 开始,因此:
s[0]是第一个字符。s[1]是第二个字符。s[s.size() - 1]是最后一个字符,前提是字符串非空。
通过非 const 字符串对象的下标访问,还可以修改字符:
string s = "hello";
s[0] = 'H';
cout << s << endl;
输出:
Hello
访问字符时必须保证下标合法,否则可能出现未定义行为。
6.2 使用迭代器遍历
string 支持迭代器。
string s = "hello";
for (auto it = s.begin(); it != s.end(); ++it)
{
cout << *it << " ";
}
输出:
h e l l o
其中:
begin()返回指向第一个字符的迭代器。end()返回指向最后一个字符之后位置的迭代器。*it用于访问迭代器指向的字符。
需要记住,end() 并不指向最后一个字符本身。
6.3 使用范围 for 遍历
对于初学者,范围 for 往往是最简单的遍历方式。
string s = "hello";
for (auto ch : s)
{
cout << ch << endl;
}
如果只需要读取字符,使用 auto ch 就可以。
如果需要修改字符,使用 auto& ch。
七、string 的修改、查找与截取
7.1 push_back():尾部追加字符
string s = "hello";
s.push_back('!');
cout << s << endl;
输出:
hello!
push_back() 用于在字符串末尾追加一个字符。
7.2 append():追加字符串
string s = "hello";
s.append(" world");
cout << s << endl;
输出:
hello world
append() 可以将指定字符串追加到当前字符串末尾。
7.3 operator+=:推荐掌握的拼接方式
+= 可以追加单个字符,也可以追加另一个字符串。
string s = "hello";
s += ' ';
s += "world";
cout << s << endl;
输出:
hello world
在日常编程中,+= 通常是非常方便的字符串追加方式。
当需要连续构造字符串时,可以使用它来表达拼接意图。
7.4 c_str():获取 C 风格字符串
有些 C 语言函数或旧接口要求传入以 '\0' 结尾的字符指针,此时可以使用 c_str()。
#include <iostream>
#include <string>
#include <cstring>
using namespace std;
int main()
{
string s = "hello";
cout << strlen(s.c_str()) << endl;
return 0;
}
输出:
5
c_str() 返回指向以空字符结尾的字符序列的指针。
这个指针由 string 对象管理,通常不应该自行释放。也不要在字符串被修改或销毁后继续依赖之前获取的指针。
7.5 find():从前向后查找
find() 用于查找指定字符或子字符串。
string s = "hello world";
size_t pos = s.find("world");
if (pos != string::npos)
{
cout << "找到位置:" << pos << endl;
}
输出:
找到位置:6
find() 找到目标时返回其起始位置;找不到时返回 string::npos。
需要特别注意:
不要直接把位置 0 当成查找失败。
因为目标完全可能出现在字符串的第一个位置。
正确写法是:
if (pos != string::npos)
{
// 找到了
}
7.6 rfind():从后向前查找
rfind() 用于从后向前搜索指定字符或子字符串。
例如,获取一句话最后一个单词的长度:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s = "hello world";
size_t pos = s.rfind(' ');
size_t len = (pos == string::npos)
? s.size()
: s.size() - pos - 1;
cout << len << endl;
return 0;
}
输出:
5
这里需要考虑两种情况:
- 如果字符串包含空格,最后一个空格之后的部分就是最后一个单词。
- 如果字符串中没有空格,那么整段字符串就是一个单词。
这也是使用 rfind() 时常见的边界条件。
7.7 substr():截取子字符串
substr() 可以从指定位置开始截取一段字符串。
string s = "hello world";
string sub = s.substr(6, 5);
cout << sub << endl;
输出:
world
substr(pos, n) 中:
pos表示起始位置。n表示希望截取的字符数量。
如果没有指定第二个参数,通常会截取从起始位置到字符串末尾的内容。
使用时需要保证起始位置合法;如果起始位置超过允许范围,可能抛出 std::out_of_range。
八、string 的非成员函数与比较操作
除了成员函数,string 还支持一些常见的非成员操作。
8.1 使用 + 拼接字符串
string s1 = "hello";
string s2 = " world";
string s3 = s1 + s2;
cout << s3 << endl;
输出:
hello world
operator+ 会生成拼接后的结果。
如果在循环中反复使用 + 创建新的字符串,可能带来额外的内存分配和复制成本。对于需要持续追加内容的场景,可以考虑使用 +=,必要时提前预留容量。
8.2 使用 cin 和 cout 输入输出
string 支持直接使用输入输出运算符。
string s;
cin >> s;
cout << s << endl;
但是,cin >> s 遇到空白字符时就会停止读取。
如果输入的是:
hello world
那么通常只会读入 hello。
8.3 使用 getline() 读取整行
如果需要读取包含空格的一整行,可以使用 getline()。
#include <iostream>
#include <string>
using namespace std;
int main()
{
string line;
getline(cin, line);
cout << line << endl;
return 0;
}
输入:
hello world
输出:
hello world
在处理多组输入时,如果前面使用了 cin >>,后面紧接着调用 getline(),需要注意输入缓冲区中可能残留换行符。此时应根据输入格式正确处理换行。
8.4 使用比较运算符
string 支持常见的比较操作:
string s1 = "apple";
string s2 = "banana";
cout << (s1 < s2) << endl;
cout << (s1 == s2) << endl;
对于普通字符串,这些比较按照字符串的字典序规则进行。
需要注意,字典序比较并不等同于自然语言中的所有排序规则,也不一定等同于忽略大小写后的比较。
九、string 结构与内存管理
下面介绍不同编译器实现下 string 对象的内部结构。
这部分内容可以帮助我们理解字符串为什么能够自动管理内存,但也需要注意:std::string 的内部布局不是由 C++ 标准固定规定的,不同标准库版本和平台可能有明显区别。
9.1 为什么 string 需要管理内存?
字符串长度不是固定的。
例如:
string s = "hello";
s += " world";
当字符串追加内容时,如果现有容量不足,标准库可能需要申请新的存储空间,并将原有内容迁移过去。
因此,string 内部需要记录字符串的有效长度,并管理实际使用的存储空间。
在不同实现中,这些信息的组织方式可能不同。
9.2 小字符串优化
一些现代 string 实现会使用小字符串优化(Small String Optimization,SSO)。
其基本思想是:对于较短的字符串,直接使用对象内部的存储空间保存内容,从而减少动态内存分配。
对于较长的字符串,则可能使用动态分配的存储空间。
这种优化能够减少某些短字符串的内存分配开销。
特定 Visual C++ 实现,就是通过内部缓冲区与其他存储方式配合来管理字符串。
但是,内部缓冲区的大小、对象占用的字节数等具体细节取决于实现,不应该把某一版本的结构当作所有编译器的固定规则。
9.3 如何理解容量和有效长度?
可以把字符串想象成一个有空位的储物盒:
size()表示目前已经放入的有效字符数量。capacity()表示当前容量能够容纳的字符数量。reserve()用于请求准备更多空间。resize()用于改变有效字符数量。
理解这几个概念后,就能更容易分析字符串追加、扩容和内存管理等问题。
十、经典面试问题:浅拷贝与深拷贝
在掌握标准 string 的使用方法后,进一步讨论如何模拟实现一个简化版字符串类。
这部分知识涉及 C++ 类与对象、动态内存管理、拷贝构造函数、赋值运算符重载以及析构函数,是面试中常见的考查方向。
10.1 一个存在问题的 String 类
假设我们自己实现一个字符串类:
#include <cstring>
class String
{
public:
String(const char* str = "")
{
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
~String()
{
delete[] _str;
}
private:
char* _str;
};
构造函数申请一块动态内存,并把字符串内容复制进去。
析构函数负责释放这块内存。
乍一看,这个类似乎没有问题。
但是,如果执行下面的代码:
String s1("hello");
String s2(s1);
问题就出现了。
我们没有显式定义拷贝构造函数,因此编译器生成的默认拷贝构造会逐成员复制对象中的内容。
由于 _str 是指针,复制指针只会复制地址,不会自动复制它指向的字符串内容。
结果就是:s1 和 s2 指向同一块内存。
当两个对象先后析构时,它们可能尝试重复释放同一块内存,导致程序出现未定义行为。
这种只复制对象中成员值的行为,在资源管理类中通常称为浅拷贝问题。
10.2 什么是浅拷贝?
浅拷贝可以理解为:复制对象中的成员值,但不为其管理的资源创建独立副本。
假设对象内部有一个指针:
s1._str ─────┐
├──> 同一块动态内存
s2._str ─────┘
两个对象共享同一个地址。
如果一个对象释放了这块内存,另一个对象中的指针就可能变成悬空指针。
继续使用它,或者再次释放它,都可能导致严重问题。
因此,对于直接管理动态资源的类,不能不加思考地依赖默认的浅拷贝行为。
10.3 什么是深拷贝?
深拷贝的基本思想是:复制对象时,为新对象分配独立资源,再复制原对象的内容。
示意图如下:
s1._str ───────> 内存区域 A:"hello"
s2._str ───────> 内存区域 B:"hello"
两个对象保存的内容相同,但它们管理的内存互相独立。
这样,一个对象析构时释放自己的内存,不会影响另一个对象。
10.4 深拷贝的 String 实现
下面给出一个简化示例:
#include <cassert>
#include <cstring>
class String
{
public:
String(const char* str = "")
{
assert(str != nullptr);
size_t len = strlen(str);
_str = new char[len + 1];
strcpy(_str, str);
}
String(const String& other)
{
size_t len = strlen(other._str);
_str = new char[len + 1];
strcpy(_str, other._str);
}
String& operator=(const String& other)
{
if (this != &other)
{
size_t len = strlen(other._str);
char* newStr = new char[len + 1];
strcpy(newStr, other._str);
delete[] _str;
_str = newStr;
}
return *this;
}
~String()
{
delete[] _str;
}
private:
char* _str;
};
这段代码实现了:
- 构造函数:申请空间并复制初始内容。
- 拷贝构造函数:为新对象分配独立空间。
- 赋值运算符:先复制内容,再替换原有资源。
- 析构函数:释放对象管理的空间。
这里的 assert() 用于表达参数必须非空的前提。需要注意,断言在关闭断言检查的构建配置下可能不执行;如果类需要在所有构建配置下都可靠地处理空指针,还应明确设计相应的错误处理策略。
此外,实际工程中的资源管理代码还需要考虑异常安全等问题。上面的示例主要用于解释深拷贝的核心思路。
10.5 为什么赋值运算符要返回引用?
注意以下声明:
String& operator=(const String& other);
它返回 String&,也就是当前对象的引用。
在赋值完成后:
return *this;
这样可以支持连续赋值等常见语法,同时避免为了返回结果而额外复制对象。
例如:
a = b = c;
赋值运算符通常会先完成 b = c,然后再完成 a = b。
10.6 现代写法:拷贝与交换
介绍一种更简洁的思路:拷贝与交换(Copy-and-Swap)。
其核心是:
- 通过值传递参数获得一个副本。
- 使用
swap()交换当前对象和副本管理的资源。 - 函数结束时,临时副本析构并释放原先的资源。
示意代码:
#include <cstring>
#include <utility>
class String
{
public:
String(const char* str = "")
{
str = (str == nullptr) ? "" : str;
size_t len = std::strlen(str);
_str = new char[len + 1];
std::strcpy(_str, str);
}
String(const String& other)
{
size_t len = std::strlen(other._str);
_str = new char[len + 1];
std::strcpy(_str, other._str);
}
String& operator=(String other)
{
std::swap(_str, other._str);
return *this;
}
~String()
{
delete[] _str;
}
private:
char* _str;
};
这里的赋值运算符参数不是引用,而是按值传递。
当传入另一个 String 对象时,会先构造参数副本。随后交换指针,原对象获得副本的资源,而参数副本接管原对象的旧资源,并在函数结束时自动释放它。
这种写法有助于简化赋值过程,并改善异常安全性。
需要注意,这个示例用于说明原理,并不是完整的生产级字符串类。实际设计还应考虑移动语义、异常处理、接口完整性等因素。
十一、写时拷贝是什么?
介绍写时拷贝(Copy-on-Write,COW)。
写时拷贝的基本思想是:多个对象在没有修改内容时,可以共享某份资源;当其中一个对象需要修改内容时,再为其创建独立副本。
例如:
对象 A ───┐
├──> 共享字符串资源
对象 B ───┘
当对象 B 需要修改字符串时,再为它创建独立资源:
对象 A ───────> 原字符串资源
对象 B ───────> 新字符串资源
实现思路中,还会使用引用计数记录共享资源的使用者数量。
当对象创建或销毁时,引用计数随之变化;只有不再有对象使用该资源时,才释放对应内存。
不过,写时拷贝是一种历史上使用过的实现策略,并不是现代 std::string 必须采用的机制。具体实现取决于标准库及其版本,不能把某种历史实现直接当作所有现代 C++ 环境的行为。
对于初学者,先理解浅拷贝与深拷贝的区别,再了解写时拷贝的设计思路即可。
十二、经典 string 算法练习
掌握常见接口之后,就可以通过算法练习巩固字符串处理能力。
12.1 仅反转字母
题目要求:给定一个字符串,只反转其中的英文字母,其他字符的位置保持不变。
例如:
输入:a-bC-dEf
输出:f-Ed-Cba
可以使用双指针解决。
思路:
- 左指针从字符串开头开始。
- 右指针从字符串末尾开始。
- 左指针跳过非字母字符。
- 右指针跳过非字母字符。
- 如果两个指针都指向字母,就交换它们。
- 重复以上过程,直到两个指针相遇。
示例代码:
#include <string>
#include <utility>
using namespace std;
bool isLetter(char ch)
{
return (ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z');
}
string reverseOnlyLetters(string s)
{
if (s.empty())
{
return s;
}
size_t left = 0;
size_t right = s.size() - 1;
while (left < right)
{
while (left < right && !isLetter(s[left]))
{
++left;
}
while (left < right && !isLetter(s[right]))
{
--right;
}
if (left < right)
{
swap(s[left], s[right]);
++left;
--right;
}
}
return s;
}
该算法的时间复杂度为 (O(n)),其中 (n) 为字符串长度;除输入字符串外,算法只使用常数个额外变量。
这里的 isLetter() 只识别基本拉丁字母 A--Z 和 a--z,不包含其他语言的字母。
12.2 找出字符串中第一个只出现一次的字符
题目要求:找到字符串中第一个只出现一次的字符,并返回它的位置;如果不存在,则返回 -1。
例如:
输入:leetcode
输出:0
其中字符 l 只出现一次,而且是最早出现的此类字符。
解题思路:
第一遍遍历统计字符出现次数,第二遍遍历寻找第一个计数为 1 的字符。
示例代码:
#include <string>
using namespace std;
int firstUniqChar(const string& s)
{
int count[256] = {0};
for (unsigned char ch : s)
{
++count[ch];
}
for (size_t i = 0; i < s.size(); ++i)
{
unsigned char ch = static_cast<unsigned char>(s[i]);
if (count[ch] == 1)
{
return static_cast<int>(i);
}
}
return -1;
}
时间复杂度为 (O(n)),额外空间为 (O(1)),因为统计数组的大小固定为 256。
这里的实现适合统计单字节字符。对于 UTF-8 编码的中文等多字节文本,不能简单地把每个字节当作一个完整字符,需要使用适当的 Unicode 文本处理方案。
12.3 判断回文字符串
回文字符串指正着读和反着读都相同的字符串。
例如:
level
就是一个回文字符串。
如果题目要求忽略大小写,并且忽略非字母数字字符,可以采用双指针方法。
思路:
- 左右指针分别指向字符串两端。
- 跳过非字母数字字符。
- 将需要比较的字符统一转换为相同大小写。
- 如果左右字符不同,说明不是回文。
- 如果所有字符都能匹配,则说明是回文。
示例代码:
#include <cctype>
#include <string>
using namespace std;
bool isLetterOrNumber(char ch)
{
return (ch >= '0' && ch <= '9') ||
(ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z');
}
bool isPalindrome(string s)
{
size_t left = 0;
size_t right = s.size();
while (left < right)
{
while (left < right && !isLetterOrNumber(s[left]))
{
++left;
}
while (left < right && !isLetterOrNumber(s[right - 1]))
{
--right;
}
if (left < right)
{
unsigned char a = static_cast<unsigned char>(s[left]);
unsigned char b = static_cast<unsigned char>(s[right - 1]);
if (tolower(a) != tolower(b))
{
return false;
}
++left;
--right;
}
}
return true;
}
该实现按 ASCII 字母和数字的规则处理字符,适用于常见的英文回文题目。对于 Unicode 文本,需要额外考虑字符编码与大小写转换规则。
12.4 字符串相加
题目要求:给定两个非负整数的字符串形式,返回它们相加的结果。
例如:
输入:
num1 = "123"
num2 = "789"
输出:
"912"
如果数字非常大,可能超过内置整数类型的表示范围。这时可以直接按照字符串逐位计算。
思路:
- 从两个字符串的末尾开始处理。
- 取出当前位的数字。
- 加上进位。
- 计算当前位的结果和新的进位。
- 将结果从低位到高位追加到字符串中。
- 最后将结果反转。
示例代码:
#include <algorithm>
#include <string>
using namespace std;
string addStrings(const string& num1, const string& num2)
{
int i = static_cast<int>(num1.size()) - 1;
int j = static_cast<int>(num2.size()) - 1;
int carry = 0;
string result;
while (i >= 0 || j >= 0 || carry != 0)
{
int a = (i >= 0) ? num1[i--] - '0' : 0;
int b = (j >= 0) ? num2[j--] - '0' : 0;
int sum = a + b + carry;
result += static_cast<char>('0' + sum % 10);
carry = sum / 10;
}
reverse(result.begin(), result.end());
return result;
}
这道题的关键是理解十进制竖式加法中的进位规则。
如果两个数字字符串的总长度为 (n),该算法的时间复杂度为 (O(n))。
十三、string 学习路线总结
学习 string 时,建议按照由浅入深的顺序进行。
第一阶段:掌握常用接口
重点学习:
- 构造字符串。
- 使用
size()、empty()和clear()。 - 使用
resize()和reserve()。 - 使用下标、迭代器和范围
for。 - 使用
+=、find()、rfind()、substr()。 - 使用
cin、cout和getline()。
第二阶段:练习字符串算法
重点练习:
- 双指针。
- 字符统计。
- 回文判断。
- 字符串反转。
- 字符串加法。
- 单词处理与子字符串查找。
第三阶段:理解底层实现
进一步学习:
- 字符串的长度与容量。
- 动态内存分配。
- 拷贝构造函数。
- 赋值运算符重载。
- 浅拷贝与深拷贝。
- RAII、异常安全与现代 C++ 资源管理。
这三个阶段可以帮助我们从会使用 string,逐渐走向理解它的设计原理。
十四、总结
string 是 C++ 标准库中非常重要的字符串处理工具。
通过本文,我们了解了它相较于 C 风格字符串的优势,掌握了常用的构造、容量、访问、修改和查找操作,也学习了 auto、范围 for 等现代 C++ 特性。
在算法部分,我们进一步练习了双指针、字符统计和逐位运算等经典思想。
最后,通过自定义字符串类的示例,我们理解了浅拷贝可能带来的资源管理问题,以及深拷贝和拷贝与交换的基本思路。
学习 string 不应该只停留在背诵接口。真正重要的是能够根据问题选择合适的方法,并理解程序背后的数据与资源管理机制。
会用 string,可以提高字符串处理效率;理解深拷贝和内存管理,则能帮助我们进一步掌握 C++ 面向对象编程的核心思想。
为代码补充可编译示例删减重复接口讲解内容统一初学者学习主线
结尾
uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 道尔柯南 C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日目标达成!" |
结语:希望对学习C++相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!