目录
0.1概述&序言
一、OJ实战
1.1仅仅反转字母(力扣 917)
1.2找字符串中第一个只出现一次的字符(力扣 387):
1.3字符串里面最后一个单词的长度(牛客HJ1)
1.4验证一个字符串是否是回文(力扣125)
1.5字符串相加(力扣415)
1.6字符串转换整数atoi(牛客)
1.7如果对此类题还感兴趣的话,此处有几道课后作业:
二、模拟实现string
2.1一个经典的string类问题
2.2浅拷贝:两个孩子公用一个玩具
2.3深拷贝:传统版
2.4深拷贝:现代版写法(三个swap)
2.5 写时拷贝(了解)
三、最终总结
四、该系列导航,方便跳跃复习:
0.1概述&序言
这里是白杨,上两篇我们把string的接口吃透了,传送门:C++入门篇(十):string(上)——认识string:构造与三大遍历(一条龙讲透operator[]、迭代器、auto、范围for)-CSDN博客C++入门篇(十一):string(中)——容量与增删改查:一篇吃透所有常用接口(万字详解)-CSDN博客
会用只是第一步,本篇进入硬核模式:
- OJ实战:六道经典字符串体(含(上篇)的两道面试题),检验你的string熟练度。
- 模拟实现:面试官最爱问的“手写string类”——“浅拷贝为什么会崩,深拷贝怎么写,现代swap到底妙在哪?"
探本溯源,学有所得,让我们开始吧。
一、OJ实战
1.1仅仅反转字母(力扣 917)
917. 仅仅反转字母 – 力扣(LeetCode)
题目:给定一个字符串S,反转其中的字母,非字母保留在原来的位置:如:“a-bC-dEf-ghIj”→“j-Ih-gfE-dCba”。
思路:双指针,begin/end从两端向中间走,遇到非字母就跳过,遇到字母就交换。
class Solution {
public:
bool isLetter(char ch)
{
if (ch >= 'a' && ch <= 'z') return true;
if (ch >= 'A' && ch <= 'Z') return true;
return false;
}
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;
}
};
打印结果(实测):
reverseOnlyLetters("a-bC-dEf-ghIj") = j-Ih-gfE-dCba

1.2找字符串中第一个只出现一次的字符(力扣 387):
387. 字符串中的第一个唯一字符 – 力扣(LeetCode)
题目:给定一个字符串,找到它的第一个不重复的字符,返回其下标;不存在则返回-1。如“loveleetcode”返回2(第一个不重复的是‘v’)。
思路:哈希计数。开一个256大小的数组统计每个字符出现次数,在按字符次序从前往后找第一个次数为1的字符。
class Solution {
public:
int firstUniqChar(string s) {
int count[256] = { 0 };
int size = s.size();
for (int i = 0; i < size; ++i)
count[s[i]] += 1;
for (int i = 0; i < size; ++i)
if (1 == count[s[i]])
return i;
return -1;
}
};
打印结果(实测):
firstUniqChar("loveleetcode") = 2

假如面试追问:为什么第二遍从前往后扫,就能保证找到的是“第一个不重复字符”?
原因:第二遍遍历的顺序就是字符串原顺序,第一个命中“计数为1”的下标,自然是全局第一个。哈希链表顺序是乱的,所以不能直接遍历哈希表。
1.3字符串里面最后一个单词的长度(牛客HJ1)
字符串最后一个单词的长度_牛客题霸_牛客网
题目:输入一行字符串(单词之间可能多个空格),输出最后一个单词长度。如“hello world”→5。
思路:读一行带空格的字符必须用getline(cin>>遇空格停,(中篇)7.3刚踩过该坑);最后一个单词长度=总长-最后一个空格下标-1.
#include <iostream>
#include <string>
using namespace std;
int main()
{
string line;
while (getline(cin, line)) // 不要用 cin>>line,遇空格就结束
{
size_t pos = line.rfind(' '); // 从后往前找最后一个空格
cout << line.size() – pos – 1 << endl;
}
return 0;
}
打印结果(实测):
输入:hello world→输出:5
输入:nowcoder →输出:8

1.4验证一个字符串是否是回文(力扣125)
125. 验证回文串 – 力扣(LeetCode)
题目:判断字符串在“只考虑字母和数字、忽略大小写”后是不是回文。如:"A man, a plan, a canal: Panama" → true。
思路:先把小写字母同一转大写,双指针从两端向中间走,跳过非字母数字字符,比较是否相等。
小贴士:同一成大写还是小写都行,关键是统一——否则‘A'和’a‘因该相等的却判false
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) // 注意要 auto&,修改原字符串
{
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;
}
};
打印结果(实测)
isPalindrome("A man, a plan, a canal: Panama") = 1

1.5字符串相加(力扣415)
415. 字符串相加 – 力扣(LeetCode)
题目:给定两个字符串形式的非负整数,返回他们的和(字符串形式)。如“456”+“789”=“1245”。
这就是(上篇1.3)预告的第二到面试题,现在来解决它。
思路:从后往前逐位相加、处理进位。结果用+=尾插,最后reverse回来(头插insert一次挪一次,O(n^2),别用)。
class Solution {
public:
string addStrings(string num1, string num2) {
int end1 = num1.size() – 1;
int end2 = num2.size() – 1;
int value1 = 0, value2 = 0, next = 0; // next 是进位
string addret;
while (end1 >= 0 || end2 >= 0)
{
if (end1 >= 0) value1 = num1[end1–] – '0';
else value1 = 0;
if (end2 >= 0) value2 = num2[end2–] – '0';
else value2 = 0;
int valueret = value1 + value2 + next;
if (valueret > 9)
{
next = 1;
valueret -= 10;
}
else
{
next = 0;
}
addret += (valueret + '0'); // 尾插,最后再反转
}
if (next == 1)
addret += '1';
reverse(addret.begin(), addret.end());
return addret;
}
};
打印结果(实测)
“456”+“789”=1245
“11”+“123”+134

1.6字符串转换整数atoi(牛客)
把字符串转换成整数_牛客题霸_牛客网
还记得上篇 1.3 预告的面试题吗?这道"字符串转整形数字"就是它——经典中的经典,边读边转,重点在边界条件:前导空格、正负号、非数字字符、溢出。
思路:三步走——跳过前导空格→处理正负号→逐位转换。溢出检测用longlong中转,超过int范围直接返回边界值。
class Solution {
public:
int StrToInt(string s) {
int i = 0, n = s.size();
// 1. 跳过前导空格
while (i < n && s[i] == ' ') ++i;
// 2. 处理正负号
int sign = 1;
if (i < n && (s[i] == '+' || s[i] == '-'))
{
if (s[i] == '-') sign = -1;
++i;
}
// 3. 逐位转换,同时检查溢出
long long result = 0;
while (i < n && s[i] >= '0' && s[i] <= '9')
{
result = result * 10 + (s[i] – '0');
if (result * sign >= INT_MAX) return INT_MAX;
if (result * sign <= INT_MIN) return INT_MIN;
++i;
}
if(i<n)
{
return 0;
}
return (int)(result * sign);
}
};

1.7如果对此类题还感兴趣的话,此处有几道课后作业:
如果题目有问题,欢迎到评论区留言:我看到会一一为您解答问题。
二、模拟实现string
2.1一个经典的string类问题
先看代码,你觉得它有问题吗?
// 为了和标准库区分,此处使用 String
class String
{
public:
String(const char* str = "")
{
if (nullptr == str) // 传了nullptr,认为程序非法
{
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
void TestString()
{
String s1("hello bit!!!");
String s2(s1); // 调用编译器合成的默认拷贝构造——浅拷贝!
}
先说结论:有问题,而且是致命的。
这个string类没有显示写拷贝构造和赋值重载,编译器会生成默认的——默认实现是浅拷贝,两个对象共用一块空间,析构同时一块空间被释放两次,程序崩溃。
把 TestString() 放进 main 里跑一下就知道:s2 先析构,把堆空间释放了;s1 再析构,又释放一次同一块空间——重复释放,程序直接崩溃。
面试题:什么样的类必须显示写拷贝构造和赋值重载?
只要类中涉及资源的管理(申请了堆,打开了文件等),拷贝构造、赋值运算符重载、析构函数三个必须显示给出。人们给它取了一个名—— Rule of Three(三大法则)——要写就三个一起写。
2.2浅拷贝:两个孩子公用一个玩具
先听个故事:
一家两个孩子,父母只买了一份玩具。一起玩就万事大吉;一旦不想分享,你争我夺,玩具就坏了。
2.1 里的 s1、s2 就是这么"共用玩具"的——浅拷贝(位拷贝):编译器把对象里的值原样拷贝过去,指针成员只拷贝了地址,于是两个对象共享同一份资源。一个对象销毁把资源释放了,另一个还蒙在鼓里继续用——访问违规,崩溃。
成员只有 int、double 这类普通值,浅拷贝完全没问题;一旦涉及资源管理(new 出来的指针等),共享资源就是定时炸弹。
2.3深拷贝:传统版
深拷贝:每个对象都拥有一份独立的资源,不与其他对象共享。——父母给每个孩子各自买一份玩具,各玩各的。
传统版老老实实:开空间→拷贝内容→释放旧空间。注意赋值重载里的自赋值检查和先new后delete:
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(new char[strlen(s._str) + 1])
{
strcpy(_str, s._str);
}
// 赋值重载:先开好新空间,再释放旧空间(注意自赋值检查)
String& operator=(const String& s)
{
if (this != &s) // 防止 s = s:不然先把自己的空间删了,后面用啥?
{
char* pStr = new char[strlen(s._str) + 1];
strcpy(pStr, s._str);
delete[] _str;
_str = pStr;
}
return *this;
}
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
传统版的两个细节(常被追问):
2.4深拷贝:现代版写法(三个swap)
现代写法:拷贝构造借助“零时工+swap”;赋值重载参数用传值传递,让编译器自动调拷贝构造生成临时对象,再swap把资源换过来。代码量少一半,还不容易写错。
class String
{
public:
String(const char* str = "")
{
if (nullptr == str)
{
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 拷贝构造:swap(1)——临时工干活,资源换过来
String(const String& s)
: _str(nullptr) // 必须先置空!否则swap后临时对象析构释放垃圾指针
{
String strTmp(s._str); // 临时工:new + strcpy
swap(_str, strTmp._str); // 资源换过来
} // strTmp 出作用域析构:释放空指针,安全
// 赋值重载:swap(2)——值传递参数,编译器自动拷贝构造
String& operator=(String s) // s 是实参的拷贝(拷贝构造自动调用)
{
swap(_str, s._str); // 交换资源
return *this;
} // s 出作用域析构:顺带把旧资源释放了
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
三个swap逐一拆解:(见上方代码注释)
对比:现代版 vs 传统版,哪个好?现代版更优雅简洁,跟不容易出错,而且面试写现代版逼格拉满。
打印结果(实测,VS x64,两种写法都验证过):(运行截图仅展示现代写法打印结果)
modern: hello bit!!! | hello bit!!! | hello bit!!! classic: hello bit | hello bit | hello bit

2.5 写时拷贝(了解)
写时拷贝是一种"拖延症":浅拷贝 + 引用计数。 拷贝时不真拷贝,只在某个对象真正修改数据时才拷贝。
引用计数记录资源使用者的个数:
- 构造时计数置1
- 每增加一个对象使用该资源,计数+1
- 对象销毁时计数先-1,减到0(自己时最后一个使用者)才释放资源
好处是拷贝构造几乎零成本;坏处是实现复杂、计数操作有线程安全隐患。旧版 g++ 的 string 就是写时拷贝,新版本已经放弃了。了解即可
三、最终总结
四、该系列导航,方便跳跃复习:
- C++入门篇(十):string(上)——认识string:构造与三大遍历(一条龙讲透operator[]、迭代器、auto、范围for)-CSDN博客
- C++入门篇(十一):string(中)——容量与增删改查:一篇吃透所有常用接口(万字详解)-CSDN博客
- 本篇。
好了,string 三篇到此完结!下一篇进入 STL 下一个重量级容器——vector(预告:动态数组、迭代器失效问题)。如果对你有帮助,不要忘记点赞三连一波哦!!!我是白杨,我们下期见🙂。
网硕互联帮助中心





评论前必须登录!
注册