
你好,我是安然无虞。 |
文章目录
- 自学网站
- 写在前面
- 为什么要学习string类?
- 什么是string类?
- string类的底层
- string类的常用接口
- 构造函数
- 遍历访问操作
- 迭代器
- 练习题
- 容量相关操作
- 增删查改操作
- 非成员函数
- 课堂练习
- 字符串中的第一个唯一字符
- 字符串里面最后一个单词的长度
- 验证回文串
自学网站
推荐给老铁们两款学习网站:
面试利器&算法学习:牛客网 风趣幽默的学人工智能:人工智能学习
写在前面
从今天开始我们就慢慢进入C++的STL部分咯,我会讲解的比较深入,不过请铁子们放心,还是不太难理解的,跟上我的步伐即可。
不过需要注意的是,string是在C++标准库中的,不是在STL中,因为string的产生早于STL。
为什么要学习string类?
首先我们为什么要学习string类呢,学习它有什么作用呢?我们知道,在C语言中,字符串是以’\0’为结尾的一些字符的集合,为了操作方便,C标准库提供了一套str系列的库函数,比如strlen(), strstr(), strcpy()等等,但是这些库函数与字符串是分离开的,不符合OOP的思想,而且底层空间需要用户自己管理,稍不留神会导致越界访问出错。
所以在常规的工作中,为了简单、方便、快捷,基本上都会使用string类,很少会有人去使用C标准库中的字符串操作函数。
什么是string类?
关于string类的文档介绍
对了,有一点需要说明一下,从现在开始大家要尝试读英文文档,不需要一个单词一个单词去翻译,所表示的大致意思知道即可。
就像上面这段描述string类的,你只需要知道:
string是表示字符顺序序列的类,该类的接口与常规容器的接口基本一致,再添加一些专门用来操作string的常规接口。string类是basic_string模板类的一个实例,并且使用char作为它的字符类型(想深入了解,需具备一定编码相关的知识),它使用char来实例化basic_string模板类,并用char_traits和allocator作为basic_string的默认参数。
typedef basic_string<char> string;
//string是被typedef出来的,底层是basic_string
因为string是在C++标准库中定义的,所以我们在使用string类的时候,必须包含#include < string> 头文件以及using namespace std;
还有一个问题就是为什么不降string类的头文件定义成#include<string.h>,而是#include< string>呢?其实很简单,我们知道,C++是兼容C语言的,而C标准库中有string.h这个头文件,所以C++标准库为了避免命名冲突,所以才这样做的。
string类的底层
下面我们大致说说string类的底层结构:
template<class T>
class basic_string
{
public:
basic_string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
_str = new char[capacity + 1];
strcpy(_str, str);
}
//之前我们常说传引用返回是为了减少拷贝
//但是注意哦,这里传引用返回是为了支持修改
T& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
private:
const T* _str;
size_t _size;
size_t _capacity;
};
好的,这里我就简单介绍一下,大家了解一下即可,下一篇我们会详细模拟实现string的底层。
string类的常用接口
构造函数
虽然string类中给我们提供了这么多接口,但是在实际使用当中用的最多的无非两种:构造空的string类对象以及用C字符串来构造string类对象。接下来我们只操作4种,剩下的感兴趣的老铁可以下去自行操作:
string s1;//构造空的string类对象s1
string s2("hello string");//用C格式的字符串构造string类对象s2
string s3(s2);//拷贝构造string类对象s3
string s4(10, 'c');//string类对象s4中包含10个字符'c'
遍历访问操作
一共有三种方式可以遍历式访问string类对象,其中用的最多的就是方式一。
string s("hello cplusplus");
//共有三种遍历方式,其中第一种使用的最多
//1.下标+[]
for (int i = 0; i < s.size(); i++)
{
cout << s[i];
}
cout << endl;
//2.迭代器
string::iterator it = s.begin();
while (it != s.end())
{
cout << *it;
it++;
}
cout << endl;
//其中还可以通过迭代器反向遍历
string::reverse_iterator rit = s.rbegin();
while (rit != s.rend())
{
cout << *rit;
rit++;
}
cout << endl;
//3.范围for
for (auto ch : s)
{
cout << ch;
}
cout << endl;
迭代器
1. 正向迭代器
begin() + end(): begin()获取的是第一个字符的迭代器,end()获取的是最后一个有效字符的下一个位置的迭代器
string s("hello world");
string::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
it++;//正向迭代器++,向正向走
}
结果:
2. 反向迭代器
rbegin() + rend(): rbegin()获取的是最后一个有效字符的迭代器,rend()获取的是第一个字符的上一个位置的迭代器
string s("hello world");
string::reverse_iterator rit = s.rbegin();
while (rit != s.rend())
{
cout << *rit << " ";
rit++;
}
结果:
对于反向迭代器,我们需要格外注意一下,反向迭代器里的rbegin()获取的是最后一个有效字符位置的迭代器,rend()获取的是第一个字符的前一个位置的迭代器,而且反向迭代器++,是反向走的,正如上面所运行出来的结果一样。
3. 正向只读迭代器
对于正向只读迭代器呢,只能读,不能写。
void Func(const string& rs)
{
string::const_iterator it = rs.begin();
while (it != rs.end())
{
cout << *it << " ";
//(*it) += 1;编译不通过
it++;
}
}
int main()
{
string s("hello string");
Func(s);
return 0;
}
4. 反向只读迭代器
对于反向只读迭代器,也是只能读不能写。
void Func(const string& rs)
{
//string::const_reverse_iterator rit = rs.rbegin();
//上面rit的类型也太长了吧,这就体现出auto的作用了
auto rit = rs.rbegin();
while (rit != rs.rend())
{
cout << *rit << " ";
//(*it) += 1;//编译不通过
rit++;
}
}
int main()
{
string s("hello string");
Func(s);
return 0;
}
OK,关于string类的迭代器,也就是上面的这四种。说到这里,大家可能大致明白迭代器的使用,但是对于迭代器到底是什么或许还是一知半解,下面我们就来说说迭代器到底是何方神圣!
下面对这三种遍历的方式进行解剖:
方式一:下标+[ ]
string s("hello cplusplus");
for (int i = 0; i < s.size(); i++)
{
cout << s[i];
//s是自定义类型,所以会找[]运算符重载去调用
//s.operator[](i);
}
cout << endl;
我们知道上面的s是自定义类型对象,所以 s[i] 表示调用[ ]运算符重载:s.operator[] (i); 但是对于内置类型来说的话,会直接转化称指针的解引用,就像下面这样:
const char* s2 = "world";
cout << s2[i];//s2为内置类型,直接转化为*(s2+i);
方式二:迭代器
string s("hello cplusplus");
string::iterator it = s.begin();
//注意哦,这里的!=不建议写成<,后面说为什么
while (it != s.end())
{
cout << *it;
it++;
}
cout << endl;
这里的迭代器像不像指针一样的东西,下面我们看看官方库是怎么定义它的:
所以,迭代器是什么?
我们可以认为迭代器是像指针一样的东西或者就是指针。
这里也就解释了,为什么我说循环控制语句最好不要写成下面这样子:
while(it < s.end())
因为如果是链表结构,那么底层就不是按照顺序存储的了,这样地址的大小是不确定的,所以这样写是错误的,改成 != 更严谨些。
方式三:范围for
//自动取s里面的字符赋给ch,自动++,自动判断结束
for (auto ch : s)
{
cout << ch;
}
cout << endl;
我们知道,上面的范围for自动取s里面的字符赋给ch,自动++,自动判断结束,看起来是不是很高大上,不过终究只是看起来,其实范围for的底层原理是被替换成了迭代器。
下面我们通过汇编代码验证:
这是方式二迭代器的汇编代码:
这是方式三范围for的汇编代码:
OK,关于遍历访问操作和迭代器的相关问题到此就结束了,不过老铁们请放心,后面经常会使用到迭代器,咱们慢慢去感受它的奥秘吧。
下面我们做一道练习题强化一下吧。
练习题
原题链接:仅仅反转字母
题目描述:
示例:
解题思路:
这道题目其实很简单,跟我们之前学习的快排单趟很相似。
题解代码:
class Solution {
public:
//判断字母
bool isChar(char ch)
{
if(ch >= 'a' && ch <= 'z')
return true;
else if(ch >= 'A' && ch <= 'Z')
return true;
else
return false;
}
string reverseOnlyLetters(string s) {
int left = 0;
int right = s.size() - 1;
while(left < right)
{
while(left < right && !isChar(s[left]))
left++;
while(left < right && !isChar(s[right]))
right--;
swap(s[left], s[right]);
left++;
right--;
}
return s;
}
};
容量相关操作
函数名称 | 功能说明 |
size | 返回字符串有效字符长度 |
length | 返回字符串有效字符长度 |
capacity | 返回空间总大小 |
empty | 检测是否为空串,是则返回true |
clear | 清空有效字符 |
reserve | 为字符串预留空间(扩空间) |
resize | 扩空间 + 初始化 |
好,下面我们来对应练习:
void Teststring1()
{
string s("hello string");
cout << s.size() << endl;
cout << s.length() << endl;
cout << s.capacity() << endl;
cout << s << endl;
}
今天这篇博客主要是教大家使用string类的一些常用接口,至于它们底层是如何实现的,咱们在下一篇博客中进行讲解。
下面如果我们加上这行代码呢:
//将s中的字符串清空,注意哦,只是将size置为0,capacity不变
s.clear();
下面我们继续操作:
//将s中的有效字符个数增加到10个,多出位置用'a'初始化
s.resize(10, 'a');
cout << s.size() << endl;
cout << s.capacity() << endl;
cout << s << endl;
cout << endl;
//将s中的有效字符个数增加到15个,多出位置用'\0'初始化
s.resize(15);
cout << s.size() << endl;
cout << s.capacity() << endl;
cout << s << endl;
cout << endl;
//将s中的有效字符个数减少至5个,但是容量不会变
s.resize(5);
cout << s.size() << endl;
cout << s.capacity() << endl;
cout << s << endl;
cout << endl;
上面这段代码需要大家自己去敲去感受resize()这个函数。下面我们来感受一下reserve()函数:
void Teststring2()
{
string s;
//测试reserve()是否会改变string中有效元素的个数
s.reserve(100);
cout << s.size() << endl;
cout << s.capacity() << endl;
//以上,很明显reserve()不会改变string中有效元素的个数
cout << endl;
//测试reserve()参数小于string的底层空间大小时,是否会将空间缩容
s.reserve(50);
cout << s.size() << endl;
cout << s.capacity() << endl;
//以上,证明reserve()不会将空间缩小
//事实上VS下的reserve()和resize()不会缩空间,但是resize()会将有效数据个数减少,空间不会
cout << endl;
}
其实细心的你会发现,reserve()函数可以提高插入数据时的效率,因为当你事先知道要开辟多少空间的时候,使用reserve()可以避免频繁增容,减小开销。
注意哦:
- size()和length()方法底层实现原理完全相同,string中引入size()的原因是为了与其它容器的接口保持一致,一般情况下基本都是使用size();
- clear()只是将string中有效字符清空,不改变底层空间大小;
- resize(size_t n)和resize(size_t n, char c)都是将字符串中有效元素个数改变到n个,不同的是当字符个数增多时,resize(size_t n)用’\0’来填充多出的空间,resize(size_t n, char c)用字符’c’来填充多出的空间;
- reserve()为string预留空间,不改变有效元素个数。
增删查改操作
函数名称 | 功能说明 |
push_back | 尾插字符c |
append | 尾部追加一个字符串 |
operator+= | 尾部追加一个字符串或者字符 |
find | 从pos位置向后找字符c,返回其位置 |
rfind | 从从pos位置向前找字符c,返回其位置 |
substr | 从pos位置开始截取n个字符,然后将其返回 |
insert | 随机插入 |
erase | 随机删除 |
swap | 交换 |
下面我们来练习以上操作:
void Teststring1()
{
string str;
str.push_back('h');//尾插字符'h'
str.append("ello");//尾部追加字符串"ello"
str += ' ';//尾部追加一个空格
str += "world";//尾部追加字符串"world"
cout << str << endl;
cout << str.c_str() << endl;//以C语言的方式打印字符串
cout << endl;
}
获取文件后缀:
void Teststring2()
{
//获取文件后缀
string file("string.cpp");
//从前向后找字符'.'如果找到了,返回其位置
size_t pos = file.find('.');
//判断pos位置是否存在
if (pos != string::npos)//如果没找到,返回npos(后面说)
{
string suffix(file.substr(pos, file.size() - pos));
cout << suffix << endl;
}
//npos是string类里面的一个静态的成员变量
//static const size_t npos = -1;
size_t pos = file.rfind('.');
//从后向前找字符'.'如果找到了,返回其位置
if (pos != string::npos)
{
string suffix(file.substr(pos, file.size() - pos));
cout << suffix << endl;
}
//可能你会觉得rfind()跟find()相比好像没啥特殊功能鸭,那你看看下面:
string file2("string.c.tar.zip");
//这个时候如果还是用find()找到的就是.c.tar.zip
size_t pos = file2.rfind('.');
if (pos != string::npos)
{
string suffix = file2.substr(pos, file2.size() - pos);
cout << suffix << endl;
}
}
取出URL中的域名:
void Teststring3()
{
//取出URL中的域名:www.cplusplus.com
string url("https://www.cplusplus.com/reference/string/string/find/");
cout << url << endl;
size_t start = url.find("://");
if (start != string::npos)
{
start += 3;
size_t finish = url.find('/', start);
string address(url.substr(start, finish - start));
cout << address << endl;
}
}
下面说说swap()函数,我们知道,在algorithm头文件下有一个swap()函数,它是这样实现的:
template <class T>
void swap(T& a, T& b)
{
T c(a);
a=b;
b=c;
}
而string类里面也实现了一个自己的swap()函数:
那给出如下代码,你猜猜哪个效率更高?
void Teststring4()
{
string s1("hello cplusplus");
string s2("string");
s1.swap(s2);//string类的swap(),效率高,直接交换指针
swap(s1, s2);//algorithm头文件下的swap(),效率低,深拷贝交换
}
关于底层实现会在下一篇详细讲解哦。
注意:
- 在string尾部追加字符时,s.push_back(‘c’) / s.append(‘c’) / s += 'c’这三种实现方式差不多,不过一般情况下string类的+=操作用的比较多,+=操作不仅可以连接单个字符,还可以连接字符串;
- 对string操作时,如果能事先知道大概存放多少字符,可以使用reserve()将空间预留好,避免多次增容导致性能消耗。
非成员函数
以上说的都是string类的成员函数,下面我们来说说string类的非成员函数。
函数名称 | 功能说明 |
operator+ | 传值返回,尽量少用 |
operator>> | 输入运算符重载 |
operator<< | 输出运算符重载 |
getline | 获取一行字符串 |
上面提供的几个接口,自行了解即可,还有很多接口大家可以查阅文档学习哦,是时候培养查阅文档的习惯了。下面我们给出几道OJ题,来使用前面所说的这些接口。
课堂练习
字符串中的第一个唯一字符
原题链接:字符串中的第一个唯一字符
题目描述:
示例:
注意:
解题思路:
本题可以借用哈希的思想。
class Solution {
public:
int firstUniqChar(string s) {
//统计次数
int count[26] = {0};
for(auto ch : s)
{
//字符在内存中都是以ASCII存着的
count[ch - 'a']++;
}
for(int i = 0; i < s.size(); i++)
{
if(count[s[i] - 'a'] == 1)
return i;
}
return -1;
}
};
字符串里面最后一个单词的长度
原理链接:字符串里面最后一个单词的长度
题目描述:
示例:
解题思路:
本题很简单,只需要注意一点即可:不能使用cin和scanf(),因为它们遇到空格就结束了,所以如果想正确输入字符串中的空格,需要使用getline()函数。
#include <iostream>
#include <string>
using namespace std;
int main() {
string str;
//注意哦,不能使用cin和scanf(),因为它们遇到空格就结束了
getline(cin, str);
size_t pos = str.rfind(' ');//获取字符串最后一个空格的位置
cout << str.size() - pos - 1 << endl;
return 0;
}
验证回文串
原题链接:验证回文串
题目描述:
示例:
class Solution {
public:
//判断是否为小写字母或者数字字符
bool isLetterOrNumber(char ch){
if(ch >= 'a' && ch <= 'z') return true;
else if(ch >= '0' && ch <= '9') return true;
else return false;
}
bool isPalindrome(string s) {
//将所有的大写字母转化为小写字母
for(auto& ch : s){
if(ch >= 'A' && ch <= 'Z')
ch += 32;
}
int begin = 0;
int 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]){
begin++;
end--;
}
else{
return false;
}
}
return true;
}
};