1. C++ STL 核心:string 从入门到精通(面试+源码+OJ实战)
如果你正在学习C++或者准备C++面试,string类绝对是你必须掌握的核心内容之一。作为STL中最基础、最常用的组件,string不仅在日常开发中频繁使用,更是面试官最喜欢考察的知识点。我见过太多候选人因为对string理解不够深入而在面试中折戟,也见过不少开发者因为不熟悉string的特性而在实际项目中踩坑。
这篇文章将带你从string的基本使用一直深入到它的底层实现原理,最后还会通过几个经典的OJ题目来检验学习成果。无论你是C++初学者,还是有一定经验准备面试的开发者,这篇文章都能让你对string有一个全面而深入的理解。
2. 为什么需要string类?
2.1 C风格字符串的局限性
在C语言中,字符串是通过字符数组来表示的,以'\0'作为结束标志。这种表示方式存在几个明显的缺点:
-
内存管理复杂:开发者需要手动管理内存的分配和释放,稍有不慎就会导致内存泄漏或者越界访问。
-
操作不便:字符串操作需要通过一系列函数(如strcpy, strcat, strlen等)来完成,这些函数与字符串本身是分离的,不符合面向对象的设计思想。
-
效率问题:频繁的字符串拼接、查找等操作效率低下,特别是当需要扩容时,往往需要重新分配内存并复制内容。
2.2 string类的优势
C++的string类完美解决了上述问题:
-
自动内存管理:string对象会自动管理内存的分配和释放,开发者无需关心底层细节。
-
丰富的成员函数:string类提供了大量方便的操作方法,如find、substr、append等,使得字符串操作更加直观和高效。
-
安全性更高:string类会进行边界检查,避免了缓冲区溢出的风险。
-
与STL无缝集成:string可以像其他STL容器一样使用迭代器、算法等,与其他STL组件配合使用更加方便。
3. string的基本使用
3.1 头文件包含
使用string类需要包含
cpp复制#include <string>
using namespace std; // 可以使用std命名空间
3.2 常用构造函数
string提供了多种构造函数来满足不同的初始化需求:
cpp复制string s1; // 默认构造,创建空字符串
string s2("hello"); // 用C风格字符串构造
string s3(5, 'a'); // 构造包含5个'a'的字符串
string s4(s2); // 拷贝构造
string s5 = "world"; // 赋值构造
string s6(s2, 1, 3); // 从s2的第1个字符开始,取3个字符
4. string的遍历方法
4.1 下标访问
最传统的遍历方式是通过下标访问:
cpp复制string s = "hello";
for (size_t i = 0; i < s.size(); ++i) {
cout << s[i] << " ";
}
需要注意的是,使用[]操作符访问时不会进行边界检查,如果索引越界会导致未定义行为。如果需要边界检查,可以使用at()成员函数:
cpp复制for (size_t i = 0; i < s.size(); ++i) {
cout << s.at(i) << " "; // 会抛出std::out_of_range异常如果越界
}
4.2 迭代器遍历
string支持STL风格的迭代器遍历:
cpp复制for (string::iterator it = s.begin(); it != s.end(); ++it) {
cout << *it << " ";
}
C++11还引入了const_iterator和reverse_iterator等更多迭代器类型,使得遍历更加灵活。
4.3 范围for循环(C++11推荐)
C++11引入的范围for循环是遍历string最简洁的方式:
cpp复制for (char ch : s) {
cout << ch << " ";
}
如果需要修改字符串内容,可以使用引用:
cpp复制for (char &ch : s) {
ch = toupper(ch); // 将字符转为大写
}
5. string的容量操作
5.1 容量相关成员函数
string提供了多个与容量相关的成员函数:
cpp复制string s = "hello";
cout << s.size() << endl; // 5,字符串长度
cout << s.length() << endl; // 5,与size()相同
cout << s.capacity() << endl; // 当前分配的存储空间大小
cout << s.empty() << endl; // false,是否为空字符串
5.2 reserve和resize的区别
这两个函数经常被混淆,但它们的作用完全不同:
- reserve(n):预分配至少能容纳n个字符的内存空间,但不会改变字符串的内容和长度。主要用于性能优化,避免频繁扩容。
cpp复制string s;
s.reserve(100); // 预分配100个字符的空间
cout << s.size() << endl; // 0
cout << s.capacity() << endl; // >=100
- resize(n, c):改变字符串的长度为n,如果n大于当前长度,多出的部分会用字符c填充;如果n小于当前长度,会截断字符串。
cpp复制string s = "hello";
s.resize(3); // s变为"hel"
s.resize(5, 'x'); // s变为"helxx"
5.3 clear函数
clear函数会清空字符串内容,但不会释放内存(capacity不变):
cpp复制string s = "hello";
s.clear();
cout << s.size() << endl; // 0
cout << s.capacity() << endl; // 可能仍然是5或更大
如果需要释放内存,可以使用"shrink_to_fit"(C++11):
cpp复制s.shrink_to_fit(); // 请求减少capacity以匹配size
6. string的修改操作
6.1 字符串拼接
string提供了多种拼接方式:
cpp复制string s1 = "hello";
string s2 = "world";
s1 += " "; // 追加C风格字符串
s1 += s2; // 追加string对象
s1 += '!'; // 追加单个字符
s1.append("!!"); // 使用append函数
s1.append(s2, 1, 3); // 追加s2从位置1开始的3个字符
提示:+=操作符是拼接字符串的首选方式,因为它简洁高效。
6.2 插入和删除
cpp复制string s = "hello";
s.insert(2, "xxx"); // 在位置2插入"xxx",结果为"hexxxllo"
s.erase(2, 3); // 从位置2开始删除3个字符,恢复为"hello"
6.3 查找和替换
cpp复制string s = "hello world";
size_t pos = s.find("world"); // 查找子串,返回位置6
if (pos != string::npos) {
s.replace(pos, 5, "C++"); // 替换为"C++",结果为"hello C++"
}
pos = s.find_first_of("aeiou"); // 查找第一个元音字母,返回1('e')
7. string与其他类型的转换
7.1 与C风格字符串的转换
cpp复制string s = "hello";
const char* cstr = s.c_str(); // 获取C风格字符串表示
const char* data = s.data(); // C++17起,与c_str()相同
char buf[20];
size_t len = s.copy(buf, 5); // 复制前5个字符到buf
buf[len] = '\0'; // 需要手动添加结束符
7.2 与数值类型的转换
C++11引入了方便的数值转换函数:
cpp复制string s = "123";
int i = stoi(s); // 字符串转整数
double d = stod("3.14"); // 字符串转浮点数
s = to_string(42); // 数值转字符串
8. string的底层实现
8.1 SSO(Short String Optimization)
在VS的实现中,string使用了SSO优化技术:
- 对于短字符串(通常长度小于16),直接存储在对象内部的缓冲区,避免堆内存分配。
- 对于长字符串,才使用堆内存存储。
- 这种优化减少了小字符串的内存分配开销,提高了性能。
8.2 COW(Copy-On-Write)
在GCC的早期实现中,string使用了COW技术:
- 多个string对象可以共享同一块内存。
- 只有当某个对象需要修改内容时,才会真正进行拷贝。
- 这种技术减少了不必要的内存拷贝,但在多线程环境下存在性能问题。
注意:现代GCC版本已经放弃了COW实现,因为它与C++11的并发内存模型不兼容。
9. string的模拟实现
理解string的最好方式就是自己实现一个简化版本。下面是一个基本的MyString类:
cpp复制class MyString {
public:
// 构造函数
MyString(const char* str = "") {
if (str == nullptr) str = "";
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 拷贝构造(深拷贝)
MyString(const MyString& other) : _size(other._size), _capacity(other._capacity) {
_str = new char[_capacity + 1];
strcpy(_str, other._str);
}
// 现代写法拷贝构造
MyString(MyString&& other) noexcept : _str(other._str), _size(other._size), _capacity(other._capacity) {
other._str = nullptr;
other._size = 0;
other._capacity = 0;
}
// 赋值运算符
MyString& operator=(MyString other) {
swap(other);
return *this;
}
// 析构函数
~MyString() {
delete[] _str;
}
// 交换函数
void swap(MyString& other) {
std::swap(_str, other._str);
std::swap(_size, other._size);
std::swap(_capacity, other._capacity);
}
// 访问元素
char& operator[](size_t pos) {
return _str[pos];
}
const char& operator[](size_t pos) const {
return _str[pos];
}
// 容量相关
size_t size() const { return _size; }
size_t capacity() const { return _capacity; }
bool empty() const { return _size == 0; }
// 追加字符
void push_back(char ch) {
if (_size == _capacity) {
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size++] = ch;
_str[_size] = '\0';
}
// 扩容
void reserve(size_t new_capacity) {
if (new_capacity <= _capacity) return;
char* new_str = new char[new_capacity + 1];
strcpy(new_str, _str);
delete[] _str;
_str = new_str;
_capacity = new_capacity;
}
// 修改大小
void resize(size_t new_size, char ch = '\0') {
if (new_size > _size) {
reserve(new_size);
for (size_t i = _size; i < new_size; ++i) {
_str[i] = ch;
}
}
_str[new_size] = '\0';
_size = new_size;
}
// 转换为C风格字符串
const char* c_str() const {
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
这个实现包含了string的核心功能,如动态内存管理、拷贝控制、常用操作等。理解这个实现可以帮助你更好地掌握string的工作原理。
10. string在OJ中的常见应用
10.1 反转字符串
题目:编写一个函数,将字符串中的字符顺序反转。
cpp复制void reverseString(string& s) {
int left = 0, right = s.size() - 1;
while (left < right) {
swap(s[left++], s[right--]);
}
}
10.2 字符串中的第一个唯一字符
题目:找到字符串中第一个不重复的字符,返回其索引。
cpp复制int firstUniqChar(string s) {
int count[256] = {0};
for (char c : s) {
count[c]++;
}
for (int i = 0; i < s.size(); ++i) {
if (count[s[i]] == 1) {
return i;
}
}
return -1;
}
10.3 有效的字母异位词
题目:判断两个字符串是否是字母异位词(包含相同字母但顺序不同)。
cpp复制bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;
int count[26] = {0};
for (char c : s) count[c-'a']++;
for (char c : t) {
if (--count[c-'a'] < 0) return false;
}
return true;
}
10.4 字符串转换整数 (atoi)
题目:实现一个类似atoi的函数,将字符串转换为整数。
cpp复制int myAtoi(string s) {
int i = 0, sign = 1, result = 0;
while (i < s.size() && s[i] == ' ') i++;
if (i < s.size() && (s[i] == '+' || s[i] == '-')) {
sign = (s[i++] == '+') ? 1 : -1;
}
while (i < s.size() && isdigit(s[i])) {
int digit = s[i++] - '0';
if (result > INT_MAX/10 || (result == INT_MAX/10 && digit > 7)) {
return sign == 1 ? INT_MAX : INT_MIN;
}
result = result * 10 + digit;
}
return sign * result;
}
11. string的性能优化技巧
11.1 预分配空间
当你知道字符串最终会达到一定大小时,使用reserve预分配空间可以避免多次扩容:
cpp复制string s;
s.reserve(1000); // 预分配足够空间
for (int i = 0; i < 1000; ++i) {
s += 'a'; // 不会触发重新分配
}
11.2 避免临时对象
字符串拼接时,尽量减少临时对象的创建:
cpp复制// 不好的写法:创建临时对象
string s = string("hello") + " " + string("world");
// 好的写法:直接构造
string s = "hello";
s += " ";
s += "world";
11.3 使用移动语义
C++11引入了移动语义,可以高效地转移字符串资源:
cpp复制string createString() {
string s(1000, 'a'); // 大字符串
return s; // 使用移动而非拷贝
}
string result = createString(); // 高效,没有拷贝开销
12. string的常见陷阱
12.1 迭代器失效
当修改字符串时,之前获取的迭代器可能会失效:
cpp复制string s = "hello";
auto it = s.begin();
s += " world"; // 可能导致扩容
*it = 'H'; // 危险!it可能已经失效
12.2 c_str()的临时性
c_str()返回的指针在字符串修改后可能失效:
cpp复制string s = "hello";
const char* p = s.c_str();
s += " world"; // 可能导致重新分配
cout << p; // 危险!p可能指向已释放的内存
12.3 多线程安全问题
标准没有规定string的线程安全性,多个线程同时修改同一个string对象需要外部同步:
cpp复制string s;
// 线程1:
s += "hello";
// 线程2:
s += "world";
// 需要加锁保护
13. C++17和C++20中的string新特性
13.1 string_view(C++17)
string_view提供了一种轻量级的字符串视图,避免了不必要的拷贝:
cpp复制void process(string_view sv) {
// 可以像string一样操作,但不拥有数据
cout << sv.substr(1, 3);
}
string s = "hello";
process(s); // 不会拷贝
process("world"); // 也不会创建临时string
13.2 starts_with/ends_with(C++20)
C++20为string添加了方便的检查前缀/后缀的方法:
cpp复制string s = "hello world";
bool b1 = s.starts_with("hello"); // true
bool b2 = s.ends_with("world"); // true
13.3 contains(C++23)
C++23将添加contains方法检查子串:
cpp复制string s = "hello world";
bool b = s.contains("llo"); // true
14. string与其他语言的字符串比较
14.1 与Java String的比较
- 可变性:C++的string是可变的,而Java的String是不可变的。
- 编码:C++的string本质是字节序列,而Java的String是UTF-16编码的Unicode字符序列。
- 内存管理:C++需要手动管理内存(或依赖RAII),Java有垃圾回收机制。
14.2 与Python str的比较
- 编码:Python 3的str是Unicode字符串,类似于C++的u16string或u32string。
- 操作丰富度:Python的字符串操作更加丰富和方便。
- 性能:C++的string通常性能更高,特别是在频繁修改和拼接时。
15. 实际项目中的string使用建议
- 优先使用string而非C风格字符串:除非与C API交互,否则应该始终使用string。
- 注意编码问题:处理多语言文本时,考虑使用wstring或第三方库如ICU。
- 避免不必要的拷贝:对于只读操作,考虑使用string_view(C++17)。
- 预分配足够空间:对于已知大小的字符串,使用reserve避免多次分配。
- 注意线程安全:多线程环境下共享string对象需要适当的同步机制。
16. string面试常见问题
-
string的底层实现原理是什么?
- 解释SSO和COW的实现方式及其优缺点。
-
resize和reserve的区别是什么?
- resize改变字符串大小,可能修改内容;reserve只改变容量。
-
如何实现一个自定义的string类?
- 展示深拷贝、移动语义等关键实现。
-
string的迭代器什么时候会失效?
- 讨论插入、删除、扩容等操作对迭代器的影响。
-
string和vector
有什么区别? - 比较两者的接口设计和使用场景。
-
如何高效地处理大字符串?
- 讨论移动语义、预分配、避免拷贝等优化技巧。
17. 进阶学习资源
- 《Effective Modern C++》:Scott Meyers著,包含现代C++中string的最佳实践。
- 《C++ Primer》:全面介绍string和各种STL容器。
- cppreference.com:最权威的C++标准库参考,包含string的详细文档。
- GCC/Clang源码:阅读标准库实现源码,深入理解string的底层机制。
- LeetCode字符串专题:通过实际问题练习string的应用。
18. 总结
string是C++中最基础也是最重要的组件之一。通过本文的学习,你应该已经掌握了:
- string的基本用法和常用操作
- string的底层实现原理和内存管理
- string的性能优化技巧和常见陷阱
- string在算法题目中的典型应用
- 如何设计实现一个自定义的string类
在实际开发中,合理使用string可以大大提高代码的效率和可维护性。建议你多在实际项目中练习使用string,并深入理解其底层机制,这样才能真正掌握这个强大的工具。
