C++--string

C++ string 类复习笔记


一、为什么学习 string 类

1.1 C 语言字符串的痛点

C 语言字符串本质是 '\0' 结尾的字符数组,依赖 strcpy/strlen/strcmp 等库函数操作,存在三个核心问题:

  • 不符合 OOP 思想:函数与数据分离
  • 内存自行管理:malloc/free 容易遗忘或越界
  • 越界访问风险:没有边界检查

实际开发中,几乎所有人都用 std::string,很少再用 C 库字符串函数。


二、C++11 语法补充

2.1 auto 关键字 ⭐ 重点

核心原则:

  • auto 声明变量时,编译器必须在编译期推导出类型 → 必须初始化
  • auto + 指针:autoauto* 没有区别
  • 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;
};

面试要点:

  1. 拷贝构造中 _str(nullptr) 是必须的,否则 swap 后 tmp 析构时会 delete 一个随机的野指针。
  2. 赋值用传值而非传引用,利用了"传参即拷贝"的特性,省去自己创建临时对象。
  3. 现代写法代码量更少、更安全,是 C++ 惯用法(copy-and-swap idiom)。

传统 vs 现代对比:

维度 传统写法 现代写法
代码量
异常安全 需要手动保证 天然异常安全
自赋值处理 显式 if 判断 传值自然处理
可读性 直观 需要理解 swap 语义

6.4 写时拷贝 COW(Copy-On-Write)

本质: 浅拷贝 + 引用计数。

工作流程:

  1. 构造时引用计数 = 1
  2. 拷贝构造时引用计数 +1,不真正拷贝数据(浅拷贝)
  3. 读取操作:直接共享,无额外开销
  4. 写入操作:检查引用计数,> 1 则先深拷贝一份再修改("写时拷贝")
  5. 析构时引用计数 -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)。


八、复习重点分级

🔴 第一优先级(面试高频 + 实际常用)

  1. 浅拷贝/深拷贝问题:原因(共享同一块内存 → double free)、后果(崩溃)、解决方案(深拷贝)
  2. string 模拟实现:传统写法 + 现代写法都要能手写(构造/拷贝构造/赋值/析构)
  3. operator= 为什么先 new 再 delete(异常安全)
  4. OJ 题完整代码:字符串相加、验证回文、第一个只出现一次的字符

🟡 第二优先级(常用但偏应用)

  1. size() vs capacity() 区别
  2. reserve() vs resize() 区别
  3. find() / rfind() / substr() 用法
  4. c_str() 返回什么
  5. getline vs cin >>

🟢 第三优先级(知道就行)

  1. auto 使用规则(不能做参数、不能声明数组)
  2. 范围 for 底层是迭代器
  3. VS 16 字节 SSO 优化 vs g++ 写时拷贝
  4. operator+ 少用(传值返回深拷贝)

九、速记口诀

  • 构造五兄弟:空、C串、拷贝、n个c
  • 容量三兄弟:size(有效)、capacity(容量)、reserve(预留)
  • 修改五兄弟:push_back、append、+=、find、substr
  • 深拷贝三件套:拷贝构造 + 赋值重载 + 析构(类涉及资源管理必须显式定义)
  • 现代写法核心:swap 大法,传值即拷贝,临时对象自动析构
  • 头插不如尾插+reverse:头插 O(n²),尾插+reverse O(n)
相关推荐
Listen·Rain1 小时前
向量化详解
java·人工智能·spring·机器学习·ai
想你依然心痛1 小时前
AUTOSAR Classic Platform架构解析:BSW、RTE与ASW——分层、配置、代码生成
java·架构·实时互动
梦想的旅途21 小时前
企业微信外部群智能自动回复:基于RPA接口的关键词匹配方案
java·开发语言·数据库·机器人·自动化·企业微信
J_yyy1 小时前
基于Reactor的文件管理服务开发笔记
c++·笔记·reactor·多线程编程·muduo
娇气的红酒1 小时前
关于.NET VS JavaEE平台争论的沉思录
java·java-ee·.net
ZhengO_Oz1 小时前
虚拟机CentOS 网络配置【实操】
运维·服务器·网络
折哥的程序人生 · 物流技术专研1 小时前
第3篇:手写一个饮品制作模板(附代码)
java·设计模式·行为型模式·模版方法模式·钩子方法·代码复用·编程实战
SamDeepThinking1 小时前
为什么很多人学了Java,还是没真正理解类和对象?
java·后端·面试
逝水无殇2 小时前
C# 多态性详解
开发语言·后端·c#