掌握string类:从基础到实战
🔥个人主页:胡萝卜3.0
📖个人专栏: 《C语言》、《数据结构》 、《C++干货分享》、LeetCode&牛客代码强化刷题
⭐️人生格言:不试试怎么知道自己行不行
🎥胡萝卜3.0🌸的简介:
目录
一、为什么学习string类
1.1 C语言中的字符串
1.2 面试题
二、标准库中的string类
2.1 string 的使用
2.2 string 构造完全指南
2.2.1 空的string类的对象构造
2.2.2 带参构造
2.2.2.1 前n个字符构造
2.2.3 拷贝构造
2.2.3.1 部分拷贝构造
2.3 string 类的析构
2.4 C++ string 的赋值操作
2.4.1 遍历和修改
2.4.1.1 修改
2.4.1.2 求字符串的长度
2.4.1.3 遍历+修改
2.5 迭代器
2.5.1 begin+end(正向迭代器)
1、普通对象
2、const对象
2.5.2 rbegin+rend(反向迭代器)
1、普通对象
2、const 对象
2.6 auto和范围for
2.6.1 auto
2.6.2 范围for
2.7 string类对象的容量操作
2.7.1 capacity
2.7.2 reserve
2.7.3 resize
2.8 string类对象的修改操作
2.8.1 push_back(拼接字符)
2.8.2 append(追加字符串)
2.8.3 +=(拼接字符/字符串)
2.8.3.1 +
2.8.4 赋值——assign
2.8.5 insert
2.8.6 erase——删除
2.8.7 字符串的局部修改与替换——replace
三、加餐补充:string常用场景的一些实用接口和技巧
3.1 查找:find()查找字符/字符串
1、不传位置参数(用缺省参数 pos=0)
2、传位置参数
3.2 整行输入:getline()读取带空格的字符串
3.3 子串截取:substr()从指定位置取指定长度
3.4 C字符转换:c_str () 适配C语言库函数
结语:
一、为什么学习string类
1.1 C语言中的字符串
C语言中,字符串是以‘\0’结尾的一些字符的集合,为了方便操作,C标准库中提供了一些str系列的库函数,但是这些库函数与字符串是分离的,不太符合OOP的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
1.2 面试题
在OJ中,有关字符串的题目基本以string类的形式出现,而且在常规工作中,为了简单、方便、快捷,基本都使用string类,很少有人去使用C库中的字符串操作函数。
二、标准库中的string类
在学习相关内容之前,有一个问题:我们知道无论什么类型的信息,都可以用字符串来存储,那为什么还要有其他类型呢?这是因为字符串不能进行计算,就比如简单的计算器,两个字符串怎么进行计算呢?
ok,接下来,我们话不多说,直接开始:
2.1 string 的使用
- 参考文档:string - C++ Reference
在使用string类时,必须包含#include头文件以及using namespace std;
#include<string>
using namespace std;
2.2 string 构造完全指南
从上图来看,string类对象的构造有好多种,但是有些构造被使用的次数很少,我们就掌握下面几种常见的构造即可:
2.2.1 空的string类的对象构造
2.2.2 带参构造
带参构造就是用字符串作为参数用来构造s2!!!
void testString1()
{string s2("hello world");//带参构造
}
2.2.2.1 前n个字符构造
void testString1()
{string s3("hello world", 6);//用字符串的前6个字符构造
}
2.2.3 拷贝构造
拷贝构造就是用已经初始化的对象来初始化当前类型另一个要创建的对象。
2.2.3.1 部分拷贝构造
所谓的部分拷贝构造就是用已经初始化的对象的一部分来初始化当前类型另一个要创建的对象。听起来感觉有点奇怪🤔,我们来看一下:
复制从字符位置 pos 开始并跨越 len 字符的 str 部分 (如果 str 太短或 len 是 string::npos, 则直到 str 的末尾)。
简单来说:从字符串的pos位置开始复制len个字符来初始化当前类型另一个要创建的对象。
那这里有个问题:如果pos位置后面的字符个数没有len个,怎么办?
如果pos位置后面的字符个数没有len个,那就pos位置后面有多少个字符就复制多少个字符,也就是直接拷贝到末尾!!!
不知道有没有uu注意到size_t len=npos; 这里面的nops是什么?
Npos 是一个静态成员常量值,它是 size_t 类型元素的最大可能值。当这个值用作 string 成员函数中 len (或 sublen) 参数的值时,意味着 “直到字符串的末尾”,
也就是说当我们不给len 传参数时,缺省参数就是npos,那就直接拷贝到末尾。
所以上图中的代码,我们也可以这样写:
官方解释:
2.3 string 类的析构
析构这一块的内容不是很重要(仅仅是使用时不关注),到后面需要我们自己写相关的代码时就会很重要。
2.4 C++ string 的赋值操作
我们回想一下前面的赋值操作是怎么完成的?是不是用了“=”这个赋值操作符,例如:int i=10;
这个赋值运算符是不是很好用,既然我们都觉得很好用,那我们的祖师爷肯定也是这么想的。
2.4.1 遍历和修改
2.4.1.1 修改
现在有这样一个问题:假设现在我想修改一个字符串中的一个字符,我该怎么修改呢?在前面数组中的学习中,我们是不是用 [ ] 和下标的方式进行修改,这样修改是很方便的。
我们看到字符串也可以像数组一样用 [ ] 和下标的方式进行修改,这是为什么?为什么可以和数组的修改是一样的?
这要得益于:
除了上面的操作,还有一个成员函数也可以进行相同的操作:
at成员函数返回字符在 string 中 pos 位置的引用。
代码演示:
void testString1()
{string s1("hello world");cout << s1 << endl;//现在我想修改第0位置上的字符//s1[0] = 'X';s1.at(0) = 'X';cout << s1 << endl;}
总结:
at 和 [ ] 的功能差不多,最大的区别就是如果不是正确的pos,at会抛异常,而 [ ] 会断言报错!!!
2.4.1.2 求字符串的长度
求一个字符串的长度中,祖师爷给了我们两种方式去求一个字符串的长度:
一个是size:
另一个是length:
代码演示:
void testString1()
{string s1("hello world");cout<<s1.size()<<endl;string s2("hello world");cout << s2.length() << endl;}
这两种方法都返回字符串的长度,但是长度中不包含‘\0’(字符串中有‘\0’),并且推荐使用size求长度(这是因为size适用于所有求解个数,如果在二叉树中使用length求解个数,那这个length表示什么意思?这就不能一眼看出了)
2.4.1.3 遍历+修改
通过前面的学习,我们就知道如何对一个字符串进行修改了:
void testString1()
{string s1("hello world");cout << s1 << endl;for (size_t i = 0; i < s1.size(); i++){s1[i]++;}cout << s1 << endl;
}
运行结果:
2.5 迭代器
通过上面的学习,我们知道可以使用for循环+[ ] + 下标 的方式进行遍历,那只有这一种方式可以进行遍历吗?ok,那当然不是,接下来我们就来好好说一说这个迭代器是个什么玩意。
我们看到迭代器中有很多内容,我们重点学习前4个:
2.5.1 begin+end(正向迭代器)
1、普通对象
begin 是返回一个指向 string 的第一个字符的 iterator。
end是返回一个指向 string 末尾的迭代器。
这么干说,感觉有点难理解,我们通过代码来看一下:
void testString1()
{string s1("hello world");string::iterator it1 = s1.begin();while (it1 != s1.end()){(*it1)++;cout << *it1 << " ";it1++;}
}
注意:begin()是第一个数据所在的位置,end()是有效数据的下一个位置,迭代器的范围是 [ begin(),end() )
也许会有uu想问:感觉迭代器有点麻烦哎,还没有前面的循环好用呢?说实话,对于数组而言,循环确实比迭代器好用,但是迭代器是访问所有容器的通用!!!
2、const对象
const对象的迭代器该怎么写?是这样的吗?
这是一种错误写法,const对象中的内容是只能读,不能被修改,而上面的迭代器中的const修饰的迭代器本身,const修饰的是迭代器,那迭代器就无法进行++操作了,就不能进行遍历操作了。
所以,我们不能让const修饰迭代器,而是应该让const修饰迭代器中的内容
void print(const string& s)
{string::const_iterator it1 = s.begin();while (it1 != s.end()){cout << *it1 << " ";it1++;}
}
void testString1()
{string s1("hello world");print(s1);
}
记忆技巧
-
const iterator
:迭代器被"锁在原地",不能移动,但可以修改指向的内容 -
const_iterator
:内容被"锁住",不能修改,但迭代器可以自由移动
2.5.2 rbegin+rend(反向迭代器)
1、普通对象
rbegin返回一个指向 string 的最后一个字符 (即其反向开头) 的 reverse 迭代器。
rend 返回一个 reverse 迭代器,指向 string 第一个字符 (被认为是其 reverse end) 之前的理论元素。
这说的到底是啥呀?
ok,我们通过代码来看一下:
void testString1()
{string s1("hello world");string::reverse_iterator it2 = s1.rbegin();while (it2 != s1.rend()){cout << *it2 << " ";it2++;}
}
注意:begin()是最后一个数据所在的位置,end()是第一个数据的前一个位置,迭代器的范围是 ( end(),begin() ]
2、const 对象
有了上面正向迭代器中的const对象的经验,我们就可以快速的写出反向迭代器的const对象的代码:
void print(const string& s)
{string::const_reverse_iterator it2 = s.rbegin();while (it2 != s.rend()){cout << *it2 << " ";it2++;}
}
void testString1()
{string s1("hello world");print(s1);
}
迭代器的特点:
- 提供了统一的方式遍历修改容器;
- 算法可以泛型化,算法借助迭代器处理容器中的数据。
就比如说,算法库里面就实现通用的查找算法:
一个在数组查找,一个在链表中查找,就可以通过迭代器的方式进行查找,这样就可以减省时间:
2.6 auto和范围for
2.6.1 auto
auto是C++11中新加入的一个小语法,auto是一个可以自动推导类型的关键字
嗯?这是什么意思?我们通过代码来看一下:
void testString2()
{int i = 10;auto j = i;cout << j;
}
这时候就有uu想说了,auto就这~,还不如直接写int呢,还可以少写一个字母呢🤣。
其实在普通情况下,我们可以不使用auto,auto的真正使用场景是用于长类型
例如:
void testString2()
{string s2("hello bit");string::iterator ret1 = find(s2.begin(), s2.end(), 'o');if (ret1 != s2.end()){cout << "找到了" << endl;}else{cout << "没找到" << endl;}
}
我们看到上面代码中的string::iterator 是不是一个很长的对象类型,有时候写的时候还很容易写错,这个时候就可以用到auto,自动推导对象类型。
- auto推导指针类型
用auto声明指针类型时,用auto和auto*没有任何区别
- auto引用
auto引用该怎么写,是这样的吗:
上面这种写法是错误的,r2不是引用,而是int ,这是因为:r1是i的别名,r1的类型就是i的类型,所以auto推导的r2的类型应该是int。
正确写法:
2.6.2 范围for
对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此
C++11中引入了基于范围的for循环。
for循环后的括号由冒号“ :”分为两部分:
- 第一部分是范围内用于迭代的变量
- 第二部分则表示被迭代的范围
自动迭代,自动取数据,自动判断结束。
在上图的代码中存在三个自动:
- 自动取s3中的数据赋值给ch(ch 就是数据的拷贝)
- 自动往后走
- 自动判断结束
注意:
通过上面的操作,我们就可以将范围for可以作用到数组和容器对象上进行遍历
void testString3()
{string s3("hello world");for (auto ch : s3){cout << ch << " ";}
}
但是,这里有个问题:如果此时,我们想使用范围for进行修改操作,我们该怎么做?
如果按照上面的写法,ch是数据的拷贝,我们无法通过ch修改数据,那我们可以通过引用的方式进行修改操作,我是你的别名,我就可以修改你了!!!
void testString3()
{string s3("hello world");for (auto& ch : s3){ch -= 1;cout << ch << " ";}
}
当我们要进行访问的数据比较大,并且不会修改其中的数据时,我们可以这样写:
范围for的底层很简单,容器遍历实际就是替换为迭代器,这个从汇编层也可以看到。
支持迭代器的容器,都可以使用范围for,其中数组也支持(需要特殊处理,转换成指针)
2.7 string类对象的容量操作
2.7.1 capacity
capacity 返回当前为 string 分配的存储空间大小,以字节为单位。(不包含结尾的‘\0’)
ok,那我们一起来看一下string扩容的方式:
void test()
{string s1;size_t old = s1.capacity();cout << old << endl;for (size_t i = 0; i < 200; i++){s1.push_back(i);if (s1.capacity() != old){cout << s1.capacity() << endl;old = s1.capacity();}}
}
运行一下:
发现扩容倍数接近1.5倍
STL设计是一种规范,规定哪些容器和算法,要实现哪些借接口,不同的编译器平台实现的是不一样的,就比如vs下的扩容是1,5倍,但是到了gcc下就变成了2倍。
2.7.2 reserve
reserve 是请求容量的变化,请求 string 容量适应计划的大小变化,最大长度为 n 个字符
运行一下:
通过上面的操作,我们就可以将容量提升到30或者扩到比30还要大的空间容量。
那如果此时,我想用这个成员函数进行缩容操作,会是什么样的呢?
我们看到在vs编译器下,没有进行缩容操作;但是在gcc编译器下,会进行缩容操作。
但是该函数对字符串长度没有影响,也不能改变其内容。也就是说如果要进行缩容,也不会缩到5,因为该函数对内容是不改变的,有效数据个数是11,要缩容也只会缩到11!!!
所以我们建议:可以用reserve取扩容,不建议用它取缩容
这~就是reserve的用途?也就这样嘛,没啥新奇的。
其实reserve的核心作用是在我们知道要插入的字符个数时,提前进行扩容操作:
void testString4()
{string s5;s5.reserve(200);// 确定知道要插入多少字符,提前扩容size_t old = s5.capacity();cout << s5.capacity() << endl;for (size_t i = 0; i < 200; i++){s5.push_back('x');if (s5.capacity() != old){cout << s5.capacity() << endl;old = s5.capacity();}}cout << endl << endl;
}
这样就可以减少扩容的次数,可以提高效率。
那这时就有UU想问了,如果不知道要插入的字符个数,还可以进行提前扩容的操作吗?
如果不知道个数,就不能用了,因为可能导致空间浪费或者空间开小了。
2.7.3 resize
resize影响的是size,会改变里面的数据
若n<当前对象长度,编译器会删除超过第n个字符的字符,保留前n个。
若n>当前对象长度,插入字符,若空间不够,还会扩容。
2.8 string类对象的修改操作
2.8.1 push_back(拼接字符)
将字符 c 追加到 string 的末尾,使其长度增加 1。
void testString5()
{string s5("hello world");cout << s5 << endl;s5.push_back('%');cout << s5 << endl;}
通过push_back 成员函数就可以将字符插入到字符串的末尾。
2.8.2 append(追加字符串)
append是在一个字符串的后面追加一个字符串
代码演示:
void testString5()
{string s5("hello world");cout << s5 << endl;s5.append("hello bit");cout << s5 << endl;
}
2.8.3 +=(拼接字符/字符串)
对于push_back 和 append 这两个成员函数来说,使用的频率并不是很高,而运算符“+=”会经常被使用,并且使用起来很方便。
通过在当前值的末尾追加额外的字符来扩展 string。
代码演示:
void testString5()
{string s5("hello world");cout << s5 << endl;s5 += "hello bit";cout << s5 << endl;
}
注意:+=会改变自身!!!
这时候,就会有UU想问了,那如果此时我想在一个字符串的后面追加一个字符串,并且不改变原来的字符串,改怎么做呢?
ok,聪明的祖师爷已经为我们想到了解决方法:将“+”重载为全局函数。那为什么要重载为全局函数呢?我们接着看。
2.8.3.1 +
“+”可以连接两个字符串,并且不改变其中任何一个字符串的内容,而是新开一个空间来存储该字符串。
代码演示:
void testString5()
{string s5("hello world");cout << s5 << endl;cout << s5 + " hello bit" << endl;
}
“+”不是成员函数,而是全局函数,这是为了方便进行下面的操作:
void testString5()
{string s5("hello world");cout << s5 << endl;cout << s5 + " hello bit" << endl;string s6("hello world");cout << "hello bit " + s6 << endl;
}
2.8.4 赋值——assign
向字符串赋予一个新值,替换其当前内容。
代码演示:
void testString5()
{string s6("hello world");cout << s6 << endl;s6.assign("hi world");cout << s6 << endl;}
在这些成员函数中,祖师爷并没有将头删和头插加入其中,why?这是因为头删和头插的时间复杂度较高,效率低下,若真想用,可以使用 insert 和 erase 。
2.8.5 insert
insert就是将字符或者字符串插到pos位置上的字符的前一个位置。
代码演示:
void testString6()
{string s7("hello world");cout << s7 << endl;//现在我想在第二个位置前面插入bits7.insert(2, "bit");cout << s7 << endl;//现在我想在第一个位置插入1个字符‘#’s7.insert(1, 1, '#');cout << s7 << endl;
}
如果我们想头插的话,可以直接写——
s7.insert(0, 1, 字符/字符串);(0表示首位,1表示插入1个字符/字符串)
//头插,插入一个字符 s7.insert(0, 1, 'x');
2.8.6 erase——删除
erase 是删除从pos位置开始的len个字符,当len==npos时,说明删除从pos位置开始的后面所有字符,也就是后面有多少删多少;当len>size-pos时,删除从pos位置开始的后面所有字符;当len<size-pos时,就直接删除从pos位置开始的len个字符
代码演示:
void testString6()
{string s8("hello bit");//头删s8.erase(s8.begin());cout << s8 << endl;//删除第0个位置开始的1个字符s8.erase(0, 1);cout << s8 << endl;//删除第5个位置开始的2个字符s8.erase(5, 2);cout << s8 << endl;//删除第5个位置开始往后的所有字符s8.erase(2);cout << s8 << endl;
}
2.8.7 字符串的局部修改与替换——replace
我们看到上面有很多的接口,但是实际上,经常使用的就是下面几种:
将字符串中以字符 pos 开头并跨 len 字符的部分(或字符串中介于 [i1,i2) 之间的部分]替换为新内容。
代码演示:
void testString6()
{string s8("hello bit");s8.replace(5,1, "%%%");cout << s8 << endl;s8.replace(5, 3, 1,'#');cout << s8 << endl;
}
在前面的学习中,我们做到过这样的一道题:将字符串中的空格替换成“%%%”
void testString6()
{//将s2中的所有空格转换成%%%size_t pos = s2.find(' ');while (pos != string::npos){s2.replace(pos, 1,"%%%");pos = s2.find(' ',pos+3);}cout << s2 << endl;
}
上面的写法效率有点低效
我们可以创建一个空的string对象,然后遍历s2,若没有遇见空格,就直接+=;若遇见空格,就+=“%%%”
void testString6()
{string s2("hello world hello bit");cout << s2 << endl;//将s2中的所有空格转换成%%%size_t pos = s2.find(' ');string s3;for (auto ch : s2){if (ch != ' '){s3 += ch;}else{s3 += "%%%";}}cout << s3 << endl;s2 = s3;cout << s2 << endl;}
三、加餐补充:string常用场景的一些实用接口和技巧
-除了前面讲的一些以外,其实我们的string还有很多比较实用的接口,这里就再给大家分享一部分,如果有没分享到但是大家需要使用的话可以自己查阅参考文档去了解一下用法。
3.1 查找:find()查找字符/字符串
find() 从左往右找字符或者子串,返回第一次出现的下标;没有找到就返回 string :: npos(这个代表一个很大的数,代表“不存在”)
1、不传位置参数(用缺省参数 pos=0)
查找字符代码演示:
string s1("hello carrot");
//查找字符'c'
size_t pos = s1.find('c');//当我们不传位置时,默认从0位置开始查找
if (pos != string::npos)
{cout << "找到了,下标为:" << pos << endl;
}
else
{cout << "没有找到" << endl;
}
查找字符串代码演示:
string s1("hello carrot");
//查找子串“rro”
size_t pos2 = s1.find("rro");//没有传位置,默认从0开始查找
if (pos2 != string::npos)
{cout << "找到了,下标为:" << pos2 << endl;
}
else
{cout << "没有找到" << endl;
}
2、传位置参数
string s1("hello carrot");
//查找字符'c'
size_t pos = s1.find('c',5);//从pos=5的位置开始查找
if (pos != string::npos)
{cout << "找到了,下标为:" << pos << endl;
}
else
{cout << "没有找到" << endl;
}size_t pos2 = s1.find("rro", 5);//从pos=5的位置开始查找
if (pos2 != string::npos)
{cout << "找到了,下标为:" << pos2 << endl;
}
else
{cout << "没有找到" << endl;
}
3.2 整行输入:getline()读取带空格的字符串
在平常的使用中,如果使用 cin>>string 读取字符串时,默认是空格和换行作为两个字符串的间隔。而 getline() 能读取一整行的内容,包括空格。默认回车结束,也可以自己指定
代码演示(注意标注):
#include <iostream>
#include <string>
using namespace std;int main() {string s1;string s2;//cin >> s1 >> s2;//cin 默认是空格和换行作为两个字符串的间隔//cout << s1 << endl;//cout << s2 << endl;//如果此时我想输入一个带有空格的字符串到s1中,用getline//getline 默认以换行作为间隔getline(cin, s1);getline(cin, s2);cout << s1 << endl;cout << s2 << endl;//若不想使用\n作为间隔,可以指定间隔符getline(cin, str, '#');//指定碰到#结束}
3.3 子串截取:substr()从指定位置取指定长度
拷贝从pos位置开始的len个字符,并构造一个string对象返回,如果
- 传参数len,len > size,从pos位置开始直接取到末尾;
- 不传参数len,也就是len==npos,直接取到末尾,从pos位置开始,后面有多少取多少
代码演示:
int main()
{string s = "hello world";// 1. 从位置6开始,取5个字符string sub1 = s.substr(6, 5); // sub1 = "world"// 2. 从位置0开始,取5个字符string sub2 = s.substr(0, 5); // sub2 = "hello"// 3. 从位置6开始,取到末尾string sub3 = s.substr(6); // sub3 = "world"
}
ok,这样我们就可以结合前面所学的find函数进行一些操作了
void testString8()
{//substrstring filename("test.txt");size_t pos = filename.find('.');if (pos != string::npos){string suffix = filename.substr(0,pos);cout << suffix << endl;}string url = "https://legacy.cplusplus.com/reference/string/string/rfind/";size_t pos1 = url.find(':');if (pos1 != string::npos){string protocol = url.substr(0, pos1);cout << protocol << endl;size_t pos2 = url.find('/',pos1+3);if (pos2 != string::npos){string domain = url.substr(pos1 + 3, pos2 - (pos1 + 3));cout << domain << endl;string uri = url.substr(pos2 + 1);cout << uri << endl;}}
}
通过上面的操作,我们就可以得到相应的信息:
3.4 C字符转换:c_str () 适配C语言库函数
c_str 是以const char* 的形式返回C形式的字符串,C形式的字符串的特点是结尾有\0标记,有时如果我们想用C的接口进行操作,可以使用它。
代码演示:
void testString7()
{//c_str 返回C形式的字符串string filename("test.txt");//用C形式的接口打开这个文件,进行读数据FILE* fout = fopen(filename.c_str(), "w");srand(time(0));int n = 100;for (int i = 0; i < n; i++){int data = rand() % n;fprintf(fout, "%d\n", data);}
}
结语:
掌握string的创建、遍历、容量管理和跨平台注意事项,就够应对大部分日常开发和刷题场景。它的核心是 “省心高效”,不用纠结底层,专注逻辑即可。你常用string哪个接口?评论区聊聊~