STL—容器—string类
其实string类准确来说并不是容器,因为他出现的时间比STL要早,但是也可以说是容器吧。
1.为什么要学习string类?
1.1C语言当中的字符串
C语言中,字符串是以’\0’结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数,但是这些库函数与字符串是分离开的,不太符合OOP的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
1.2面试题
在OJ中,有关字符串的题目基本以string类的形式出现,而且在常规工作中,为了简单、方便、快捷,基本
都使用string类,很少有人去使用C库中的字符串操作函数。
2.标准库中的string类
2.1string类的了解
-
字符串是表示字符序列的类
-
标准的字符串类提供了对此类对象的支持,其接口类似于标准字符容器的接口,但添加了专门用于操作单字节字符字符串的设计特性。
-
string类是使用char(即作为它的字符类型,使用它的默认char_traits和分配器类型(关于模板的更多信息,请参阅basic_string)。
-
string类是basic_string模板类的一个实例,它使用char来实例化basic_string模板类,并用char_traits和allocator作为basic_string的默认参数(根于更多的模板信息请参考basic_string)。
-
注意,这个类独立于所使用的编码来处理字节:如果用来处理多字节或变长字符(如UTF-8)的序列,这个类的所有成员(如长度或大小)以及它的迭代器,将仍然按照字节(而不是实际编码的字符)来操作。
总结:
-
string是表示字符串的字符串类
-
该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
-
string在底层实际是:basic_string模板类的别名,typedef basic_string<char, char_traits, allocator> string;
-
不能操作多字节或者变长字符的序列。
在使用string类时,必须包含#include头文件<string>以及using namespace std;
我们在包含头文件的时候经常会遇到两个比较容易混淆的头文件引用#include<string.h> 和 #include <string>,两者的主要区别如下:
#include<string.h>是C语言的标准库,主要是对字符串进行操作的库函数,是基于char*进行操作的,例如常见的字符串操作函数stpcpy、strcat都是在该头文件里面声明的。
#include<string>是C++语言的标准库,该库里面定义了string类,你可以包含这个头文件,然后定义一个字符串对象,对于字符串的操作就基于该对象进行,例如:string str;
一般来说,c++的头文件不会有.h的出现,比如<iostream>
————————————————
文档方面,个人觉得这个比较好用
作为一个程序员,查文档是一个必须具备的基本素质,当遇到一个不熟悉的接口需要使用的时候,查文档就是最好的解决方法,因此我们需要学会怎么查文档。
2.2string类的常用接口说明
2.2.1.string类的常见构造
c++98版本中全部的构造
实例:
// 初识string类
// string类 有很多的接口和成员函数,我们主要得熟悉的使用其中的2成,剩下的8成需要我们去通过网络查询来解决
# include<string>
# include<iostream>
using namespace std;
int main()
{
// 关于string类的构造
string s1; // 构造
string s2("string"); // 直接构造
string s3(s2); // 拷贝构造
// 上面这三个最常用
string s4(10, 's'); // string类中的的不常用的特殊构造
string s5("hello", 1, 3); // string类中的不常用的特殊构造 [这里如果给的不是3 是过大的值,那么就是npos,意思就是将字符串后面有多少给多少] 这些知识都可以查文档查到
// 能使用 << 来输出是因为 库中 已经对<< 进行了重载
cout << s1 << endl; // [这里默认是空串]
cout << s2 << endl; // string
cout << s3 << endl; // string
cout << s4 << endl; // ssssssssss
cout << s5 << endl; // ell [取"hello"中的 第 1~3号元素来构造的, 正好就是ell]
string s6 = s4; // 这里还是拷贝构造
string s7 = "string"; // 这里正常来说就是发生隐式类型转化 意味着,先直接构造,在拷贝构造
// 但是这里的直接构造 + 拷贝构造发生在了同一表达式,因此编译器直接优化成直接构造
cout << s6 << endl; // ssssssssss
cout << s7 << endl; // string
return 0;
}
2.2.2string类对象的容量操作
常见的与容量相关的接口:
接口的代码使用:
1.size(重点)
size可以统计字符串的有效字符长度并返回。一般来说会用作遍历。
// string类对象有关容量的常见接口
// 1.size()
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s("hello");
cout << "s: ";
// 遍历
for (size_t i = 0; i < s.size(); i++)
{
s[i] += 1; // 写 [这个相当于对取出来的字符+1,就相当于给其ASCII码值+1]
}
for (size_t i = 0; i < s.size(); i++)
{
cout << s[i]; // 读
}
cout << endl; //s8: ifmmp [hello每个字符的ASCII码值分别+1]
return 0;
}
2.length
length也会统计字符串的有效长度并返回。其实和size是非常像的,甚至其底层的实现原理都是一样的、
其实在早期的时候用的都是length,但是后面数据结构都用size了,就统一用size了,length和size可以说就是一样的,这里推荐使用size
// 2.length
# include<string>
# include<iostream>
using namespace std;
void test_string()
{
string s1("hello, world");
cout << s1.size() << endl; // 12
cout << s1.length() << endl; // 12
}
int main()
{
test_string();
return 0;
}
3.capacity
capacity可以返回当前空间的总大小
// 3.capacity
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s("hello, world");
cout << s.capacity() << endl; // 15
// 目前s有12个空间存放数据,如果超出空间,会增容多少呢?
s += "ddddd";
cout << s.capacity() << endl; // 31
// 可以发现,直接将其增容到了2倍的容量。
return 0;
}
4.clear(重点)
清空字符串内容 【注意,空间不会被销毁,只是内容被清空了】
// 4.clear
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s("hello, world");
cout << s << endl; // hello, world
s.clear();
cout << s << endl; // 空串
// 我们清除了字符串内容,不代表空间被销毁了、
cout << s.capacity() << endl; // 15
return 0;
}
5.empyt(重点)
empty可以检测字符串是否为空串,是就返回true,不是就返回false
//5.empty
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s("hello, world");
if (s.empty())
cout << "该string类对象为空" << endl;
else
cout << "该string类对象不为空" << endl;
s.clear();
if (s.empty())
cout << "该string类对象为空" << endl;
else
cout << "该string类对象不为空" << endl;
return 0;
}
6.reserve/resize(重点)
在学习这两个接口之前,我们先来看一个代码:
这个代码可以观察到每次增容时容量的变化大小
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s;
size_t sz = s.capacity();
cout << "s_capacity grow\n";
for (int i = 0; i < 100; i++)
{
s.push_back('c'); // 也可以s += 'c'
if (sz != s.capacity()) // != 的时候就说明s进行了增容
{
sz = s.capacity(); // 将新容量给到sz
cout << "new capacity: " << sz << endl; // 将此时的容量打印出来
}
}
return 0;
}
我们发现是接近1.5倍增容的。但是我们之前知道15容量在增容的时候是按两倍增容增到31的、
规律是:空间基数越大,增容的倍数越小
因为增容是有代价的。增容越多的空间,执行越多次增容,代价就越大。
知道了这个小知识,我们就可以来看resize和reserve了。
reserve:给字符串预留空间。
其实就是给字符串开辟空间
// 6.resize/reserve
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s;
s.reserve(100); // 我们知道s需要100个空间,提前开好空间
size_t sz = s.capacity();
cout << "s_capacity grow\n";
for (int i = 0; i < 100; i++)
{
s.push_back('c'); // 也可以s += 'c'
if (sz != s.capacity()) // != 的时候就说明s进行了增容
{
sz = s.capacity(); // 将新容量给到sz
cout << "new capacity: " << sz << endl; // 将此时的容量打印出来
}
}
return 0;
}
由于我们提前开辟好了100个空间【实际上不一定会开100个,因为要考虑内存对齐,最后还要放个\0】,s没有再执行增容操作。
这样我们只增容了一次,比起自动增容的次数就减少了许多。
直接开辟了111个空间,并不是我们输入的100。【了解一下】
resize:在reserve的基础上,将有效字符的个数修改,并填充字符
int main()
{
string s;
//s.reserve(100); // 我们知道s需要100个空间,提前开好空间
s.resize(100); // 将有效字符个数修改到100,并填充\0
size_t sz = s.capacity();
cout << "s_capacity grow\n";
for (int i = 0; i < 100; i++)
{
s.push_back('c'); // 也可以s += 'c'
if (sz != s.capacity()) // != 的时候就说明s进行了增容
{
sz = s.capacity(); // 将新容量给到sz
cout << "new capacity: " << sz << endl; // 将此时的容量打印出来
}
}
return 0;
}
可以看到它相当于reserve(100)并且将有效字符个数修改到了100,并且填充了\0、
由于前100个空间被resize填充了\0,因此后面还是会执行增容操作
因此如果想后面不执行增容操作,这里要用reserve
我们还可以指定填充的字符
int main()
{
string s;
//s.reserve(100); // 我们知道s需要100个空间,提前开好空间
//s.resize(100); // 将有效字符个数修改到100,并填充\0
s.resize(100,'6'); // 将有效字符修改到100,并填充6
// 因此如果想后面不执行增容操作,这里要用reserve
size_t sz = s.capacity();
cout << "s_capacity grow\n";
for (int i = 0; i < 100; i++)
{
s.push_back('c'); // 也可以s += 'c'
if (sz != s.capacity()) // != 的时候就说明s进行了增容
{
sz = s.capacity(); // 将新容量给到sz
cout << "new capacity: " << sz << endl; // 将此时的容量打印出来
}
}
return 0;
}
可以看到不再填充\0而是填充我们指定的字符6
并且由于前100个空间都被resize填入了元素,因此后面插入100个c的时候还是会增容的
因此如果想后面不执行增容操作,这里要用reserve
总结(重要):
- size()与length()方法底层实现原理完全相同,引入size()的原因是为了与其他容器的接口保持一致,一般情况下基本都是用size()。
- clear()只是将string中有效字符清空,不改变底层空间大小
- resize(size_t n) 与 resize(size_t n, char c)都是将字符串中有效字符个数改变到n个,不同的是当字符个数增多时:resize(n)用0来填充多出的元素空间,resize(size_t n, char c)用字符c来填充多出的元素空间。注意:resize在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变。
string s1("hello, world");
s1.resize(5); // 会将原来的size减小到 5。
cout << s1 << endl; // hello
s1.resize(20, 'x'); // 将size改到20,由于前5个已经有元素了,后15个填入x【空间不够会自动扩容】
cout << s1 << endl; // helloxxxxxxxxxxxxxxx
- reserve(size_t res_arg=0):为string预留空间,不改变有效元素个数,当reserve的参数小于string的底层空间总大小时,reserver不会改变容量大小
注意:
- 上面我们讲的有关容量的接口都是常用的,并不是全部。全部的接口可以去文档中查看
- 在查看接口的时候,目前来说只需要查看接口如何使用,不用查看接口的底层实现,因为以目前的水平,是完全看不懂的。
- 常用的接口需要熟悉使用、
2.2.3string类对象的访问及遍历操作
下面我们对于遍历一共有3种方法【[] + 下标】【迭代器】【范围for】
拓展:迭代器
我们来了解一下迭代器
如图所示,迭代器一共四种,这里我们就是了解一下,具体的我们后面会详细学习。
遍历的代码:
void test_string3()
{
// 迭代器一共有4种迭代器,这里在介绍一种
string s("hello world");
// 倒着遍历
string::reverse_iterator rit = s.rbegin(); //要用rbegin,指向字符串的最后一个有效元素
// auto rit = s.rbegin();
while (rit != s.rend()) // rend,指向字符串的首元素
{
cout << *rit;
rit++; // 其实是倒着走的
}
cout << endl;// dlrow olleh
}
int main()
{
// [] + 下标 【平常推荐使用这个】
string s("hello");
cout << "s: ";
// 遍历
for (size_t i = 0; i < s.size(); i++)
{
s[i] += 1; // 写 [这个相当于对取出来的字符+1,就相当于给其ASCII码值+1]
}
for (size_t i = 0; i < s.size(); i++)
{
cout << s[i]; // 读
}
cout << endl; //s8: ifmmp [hello每个字符的ASCII码值分别+1]
// 迭代器【每个容器都会有自己的迭代器】【迭代器一共有四种】(这里了解一下迭代器)
string::iterator it = s.begin(); // string::iterator 实际是一个类型
//auto it = s1.begin(); // 也可以这样写 [自动推演]
// 遍历
while (it != s.end())
{
*it -= 1; // 写
++it; // 迭代
}
it = s.begin(); // 重置
while (it != s.end())
{
cout << *it; // 读
++it; // 迭代
}
cout << endl;// hello
test_string3(); // 另外一种迭代器的使用
// 上述是迭代器的使用方法,熟悉一下就行,后面会详细学习。
// 范围for(c++11) [原理其实就是迭代器]
for (auto ch : s) // 自动把s8从左到右依次给到ch auto会自动推断类型
{
cout << ch;
}
cout << endl; //hello
return 0;
}
再来看一个场景:
- 将字符串转化为整数
// 将传进来的字符串转化成整数 【使用迭代器】
int string2int(const string& str)// 用引用提高效率,并且不改变str,const修饰
{
int val = 0;
string::const_iterator cit = str.begin(); // 此时的str是const对象,返回的是const类型的迭代器类型
while (cit != str.end())
{
val *= 10;
val += (*cit - '0');
}
cout << val << endl;
}
int main()
{
// 将字符串转化成整数
string s("12345");
int val = 0;
for (int i = 0; i < s.size(); i++) // 遍历
{
// 让val + 上取出来的数字,然后再*10, 循环,直至全部取出、
val *= 10;
val += (s[i] - '0'); // -'0'是因为 取出来的是字符 而不是整形, - 去'0'的ASCII值才是对应整形
}
cout << val << endl; // 12345
string s1("12345");
int ret = string2int(s1);
cout << ret << endl; // 12345
return 0;
}
通过上面两个例子,我们了解到了遍历有不同的方式去遍历。
在这里面当中,迭代器似乎是一个比较麻烦的使用方式。
从方向上分:正向和反向迭代器
从权限上分:普通迭代器,const迭代器。
因此使用的时候似乎不吃香,从短期来看确实是如此,因为经过了c语言的学习,我们已经习惯于用数组的方式去遍历。但是数组这个方式只能用于vector 和 string。等到后面更多的数据结构,也就是容器出现,迭代器就会频繁使用,因为+所有容器的迭代器使用方法都是类似的。
因此迭代器的学习是c++中一个重要的部分。迭代器可以用于所有STL库中的容器的遍历
2.2.4string类对象的修改操作
常见的几个与修改操作相关的接口
1.push_back/append/+=(重要)
push_back:在字符串后边尾插字符
append:在字符串后边追加字符串
+=:在字符串后边追加字符串
这三个我们推荐**+=**
有关尾插的三个接口的使用的代码:
// 1.+= / push_back / append 【有关尾插的三个接口】
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s1;
s1.push_back('p');
s1 += 'p'; // 推荐使用+=
cout << s1 << endl; //pp 可以看到 += 和 调用尾插接口的结果是一样的
s1 += "hello"; // 这里调用的是另外一个 += 的重载函数
cout << s1 << endl; // pphello
s1.append(" world"); // 在s1后尾插" world"字符串
cout << s1 << endl; // pphello world
// 在上述三个尾插的操作接口中,我们推荐使用+=
return 0;
}
如果想在具体位置插入的话可以使用insert接口,想删除具体位置的话用erase接口
可以上文档查具体使用方法,这里不做详解
2.c_str
c_str: 会将string类对象以const char 的格式返回*
他会在string类对象中找到\0 ,把\0之前的字符串内容当做一个 const char 类型返回*
代码操作:
//2.c_str
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s("hello");
const char* str = s.c_str();// str拿到的是const char* 类型。
// 获取字符数组的首地址,用c语言中的字符串形式去遍历
while (*str) // 只要*str不是\0就继续
{
cout << *str;
str++;
}
cout << endl; // hello
// 也可以直接输出
cout << s << endl; // hello
// 这里调用的是string类中的重载的<<, 它会将整个s进行输出
cout << s.c_str() << endl; // hello
// 这里相当于直接对 const char* 进行输出,遇到\0就会结束输出
s += '\0';
s += ' ';
s += "world";
cout << s << endl; // hello world 中间的\0是不可见字符,不会打印出来
cout << s.c_str() << endl; // hello 因为从h到 \0 被当做了一个const char*,s.c_str()返回的就是 hello
return 0;
}
拓展:编码表
我们知道一个中文占据两个字节,这是由编码表实现的,因为中文的编码表是GDK
而英文的编码表是ASCII表
编码的本质是:映射,将对应的值映射到表中的某一个位置。
我们来看一段代码:
// 编码表
# include<string>
# include<iostream>
using namespace std;
int main()
{
// 我们知道英文是用ASCII码表来编码的
string s = "中国"; // 中文使用GDK来编码的
cout << s << endl; //中国
s[3] = -7;
cout << s << endl; // 中郭
s[3] = -8;
cout << s << endl; // 中锅
s[3] = -9;
cout << s << endl; // 中棍
return 0;
}
下图是s字符串中存储的信息:
可以很清楚的看到中文是两个字节共同编码成一个中文。
3.find/rfind/substr(重要)
find: 可以查询字符串中的某个字符
如果找不到会返回npos【rfind找不到也一样返回npos】
npos是-1 size_t的-1相当于int类型的最大值,42亿多
substr:可以返回指定位置以后得字符串。如果不指定就是默认从头开始
来看一段代码:
//3.find/rfind
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s("text.cpp"); // 如果我们想查询这个后缀.cpp
// 我们就可以用find去查询. 再将其后边的打印出来
size_t pos = s.find('.'); // pos = 4
if (pos != string::npos) // npos是-1 size_t的-1相当于int类型的最大值,42亿多
{
// 用substr接口实现.后的打印
cout << s.substr(pos) << endl; // .cpp
// s.substr(pos)就是打印pos之后的数据,包括pos
}
return 0;
}
rfind:可以查询字符串中的一个字符,并且是最后一次出现的
//3.find/rfind
# include<string>
# include<iostream>
using namespace std;
int main()
{
string s("text.cpp"); // 如果我们想查询这个后缀.cpp
// 我们就可以用find去查询. 再将其后边的打印出来
size_t pos = s.find('.'); // pos = 4
if (pos != string::npos) // npos是一个-1 size_t的-1相当于int类型的最大值,42亿多
{
// 用substr接口实现.后的打印
cout << s.substr(pos) << endl; // .cpp
// s.substr(pos)就是打印pos之后的数据,包括pos
}
// 还会有一种情况,如果想查询的字符串有多个后缀,我们想输出最后一个后缀的话
// 我们就可以用rfind 重复的查找. 找到最后一个.
string s1("test.cpp.zip"); // pos1 = 8
size_t pos1 = s1.rfind('.');
if (pos1 != string::npos)
{
cout << s1.substr(pos1) << endl; // .zip
}
return 0;
}
拓展:网址
平常我们看到的网址是由协议、域名、资源名称
// 网址
# include<string>
# include<iostream>
using namespace std;
// 协议、域名、资源名称
int main()
{
string url("https://gitee.com/wzf-sang");
//https是协议 http和https的差别就是https是加密的
///gitee.com其实是个ip但是绑定了域名。如果不绑定域名这里是个ip【域名更好记住】
//wzf-sang就是资源名称。
//【在我们输入网址敲回车之后,服务器做的第一件事就是将你输入的网址拆成协议、域名、资源名称】
//【因此才能在对应协议下申请访问对应的域名并根据资源名称返回资源】
// 现在有个要求:分离出url。
size_t i1 = url.find(':'); // i1 = 5
if (i1 != string::npos)
{
cout << url.substr(0, i1) << endl; // https
//从0这个下标,输出i1个元素
}
size_t i2 = url.find('/', i1 + 3); // 从i1 + 3开始找
if (i2 != string::npos)
{
cout << url.substr(i1 + 3, i2 - i1 - 3) << endl;// gitee.com
// 从i1 + 3这个下标,输出i2 - i1 - 3个元素
}
cout << url.substr(i2 + 1) << endl; //wzf-sang
return 0;
}
而我们可以将其功能分离出来,抽象成一个函数
// 网址
# include<string>
# include<iostream>
using namespace std;
// 该函数用于分离网址的三个必要信息
void spilt_url(const string& url)
{
//【在我们输入网址敲回车之后,服务器做的第一件事就是将你输入的网址拆成协议、域名、资源名称】
//【因此才能在对应协议下申请访问对应的域名并根据资源名称返回资源】
// 现在有个要求:分离出url。
size_t i1 = url.find(':'); // i1 = 5
if (i1 != string::npos)
{
cout << url.substr(0, i1) << endl; // https
//从0这个下标,输出i1个元素
}
size_t i2 = url.find('/', i1 + 3); // 从i1 + 3开始找
if (i2 != string::npos)
{
cout << url.substr(i1 + 3, i2 - i1 - 3) << endl;// gitee.com
// 从i1 + 3这个下标,输出i2 - i1 - 3个元素
}
// 在i2这个下标后面的都是资源名称
cout << url.substr(i2 + 1) << endl; // wzf-sang
// + 1是因为i2是 / ,i2后面的才是资源名称
}
// 协议、域名、资源名称
int main()
{
string url("https://gitee.com/wzf-sang");
string url2("https://leetcode.cn/problems/majority-element/description/?envType=study-plan-v2&envId=top-interview-150");
//https是协议 http和https的差别就是https是加密的
///gitee.com其实是个ip但是绑定了域名。如果不绑定域名这里是个ip【域名更好记住】
//wzf-sang就是资源名称。
spilt_url(url);
// https
// gitee.com
// wzf - sang
spilt_url(url2);
//https
//leetcode.cn
//problems/majority-element/description/?envType=study-plan-v2&envId=top-interview-150
return 0;
}
2.2.5string类非成员函数
上面的几个接口了解一下,下面的OJ题目中会有一些体现他们的使用。string类中还有一些其他的操作,这里不一一列举,在需要用到时不明白了查文档即可。
2.2.6.vs和g++下string结构的说明
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节。
- vs下string的结构
string总共占28个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字
符串的存储空间:
当字符串长度小于16时,使用内部固定的字符数组来存放
当字符串长度大于等于16时,从堆上开辟空间
这种设计是合理的,大多数情况下字符串的长度都小于16,那string对象创建好之后,内
部已经有了16个字符数组的固定空间,不需要通过堆创建,效率高。
其次:还有一个size_t字段保存字符串长度,一个size_t字段保存从堆上开辟空间总的容量
最后:还有一个指针做一些其他事情。
故总共占16+4+4+4=28个字节
- g++下string的结构
G++下,string是通过写时拷贝实现的,string对象总共占4个字节,内部只包含了一个指针,该指
针将来指向一块堆空间,内部包含了如下字段:
-
空间总大小
-
字符串有效长度
-
引用计数
struct _Rep_base
{
size_type _M_length;
size_type _M_capacity;
_Atomic_word _M_refcount;
};
- 指向堆空间的指针,用来存储字符串。