这一篇博客,我们正式进入STL中的容器的字符串类的学习,C++标准模板库(STL)中的std::string
类是一个用于表示和操作字符串的类。它封装了动态分配的字符数组,提供了丰富的成员函数来进行字符串的操作,例如拼接、查找、替换、比较等。std::string
类还支持运算符重载,如+
用于字符串拼接,==
用于字符串比较,[]
用于访问字符等。它简化了C风格字符串的处理,使得字符串操作更加安全和便捷。我们学习主要是从两个方面学习:1、如何使用它。2、底层的原理。3、模拟实现。
目录
一、为什么学习string类?
1.1 C语言中的字符串
二、标准库中的string类
2.1 string类(了解)
2.2 总结
2.3 string类的常用接口说明(最常用的接口)
2.3.1 string类对象的常见构造函数
2.3.2string类对象的访问及遍历操作
2.3.3 string类对象的容量操作
2.3.4. string类对象的修改操作
2.3.5 string类非成员函数
2.3.6 vs和g++下string结构的说明
三、练习题
3.1 仅仅反转字母
3.2 找字符串中第一个只出现一次的字符
3.3 字符串里面最后一个单词的长度
3.4 验证一个字符串是否是回文
四、string类的模拟实现
4.1 实现一个简单的string =>深浅拷贝问题
4.2 浅拷贝
4.3 深拷贝
一、为什么学习string类?
1.1 C语言中的字符串
C语言中,字符串是以'\0'结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数, 但是这些库函数与字符串是分离开的,不太符合面向对象的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
在OJ中,有关字符串的题目基本以string类的形式出现,而且在常规工作中,为了简单、方便、快捷,基本都使用string类,很少有人去使用C库中的字符串操作函数。
二、标准库中的string类
2.1 string类(了解)
- 字符串是表示字符序列的类
- 标准的字符串类提供了对此类对象的支持,其接口类似于标准字符容器的接口,但添加了专门用于操作单字节字符字符串的设计特性。
- string类是使用char(即作为它的字符类型,使用它的默认char_traits和分配器类型(关于模板的更多信 息,请参阅basic_string)。
- string类是basic_string模板类的一个实例,它使用char来实例化basic_string模板类,并用char_traits 和allocator作为basic_string的默认参数(根于更多的模板信息请参考basic_string)。
- 注意,这个类独立于所使用的编码来处理字节:如果用来处理多字节或变长字符(如UTF-8)的序列,这个类的所有成员(如长度或大小)以及它的迭代器,将仍然按照字节(而不是实际编码的字符)来操作。
2.2 总结
1. string是表示字符串的字符串类,它就是一个自定义类型,可以用它来实例化字符串类对象。
2. 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作成员函数以及描述这个字符串类属性的一些成员变量。
3. string在底层实际是:basic_string模板类的别名,typedef basic_string string;
4. 不能操作多字节或者变长字符的序列。
在使用string类时,必须包含#include头文件(#include<string>)以及using namespace std;
2.3 string类的常用接口说明(最常用的接口)
2.3.1 string类对象的常见构造函数
#include <iostream>
#include <string> //必须要加这个头文件,因为标准库std它是分文件写的
using namespace std; //string类的实现都在std标准命名空间下
int main()
{
string s1; //1、实例化s1对象,无参构造函数
string s2("hello"); //2、实例化s1对象,带参构造函数
string s3(s2); //3、拷贝构造函数
string s4(10,'a'); //4、带参的拷贝构造函数,字符串初始化为10个a
string s5="hello"; //注意:这里是先用字符串"world"构造出一个临时的字符串对象,然后用这个临时的对象再去拷贝构造s5这个对象!然后被编译器直接优化成一步:直接去构造s5对象!它和第2个是等价的!
string s6=s2; //注意:这里看起来是赋值,其实是调用拷贝构造函数!
cout<< s1 << endl; //这里打印是空串,是因为C++STL中的string设计中默认会给字符串加上'\0'
cout<< s2 << endl;
cout<< s3 << endl;
cout<< s4 << endl;
cout<< s5 << endl;
cout<< s6 << endl;
s1 = s2; //这里是赋值运算符重载(string类已经实现好的)
cout<< s1 << endl;
return 0;
}
/*************************字符串的插入***************************************/
int main()
{
string s("12345");
s.push_back('6'); //字符串尾部插入单个字符
s.append("78"); //字符串尾部插入字符串
下面是使用运算符重载的方式:
s+='1'; //字符串尾部插入单个字符
s+="2222"; //字符串尾部插入字符串
cout<< s <<endl;
由此可见:字符串的插入使用+=运算符更加的方便!!推荐使用
string s;
s+= "zhang";
s+='-';
s+="子杰";
cout<<s<<endl;
return 0;
}
/****************************实现字符串转整型******************************/
int main()
{
string s("12345");
int val=0;
for(int i=0; i<s.size(); i++)
{
//cout<<s[i]<<" "; [ ]遍历字符串
val*=10;
val+=s[i]-'0'; //这里是字符相减,其实就是ASCII码相减,1的ASCII码为49,0的ASCII码为48
}
cout<<val<<endl;
return 0;
}
/************************************************************************/
2.3.2string类对象的访问及遍历操作
字符串遍历方式1:
for循环结合[ ]的运算符重载,就可以像C语言中遍历字符数粗的方式一样遍历这个字符串对象。这种方式也是使用最多的。
/*****************字符串的遍历方式1:for+operator[]下标***********************************/
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("hello");
s1 += ' ';
s1 += "world";
cout <<s1<< endl;
//对字符串进行写操作
for (int i = 0; i < s1.size(); i++)
{
s1[i] += 1;
//这里可不是重载+=运算符,这里先结合左边的[]运算符重载,返回指定i位置的字符,然后将其加1.
}
//对字符串进行读操作
for (int i = 0; i < s1.size(); i++)
{
cout << s1[i] << " " ;
}
cout << endl;
return 0;
}
/************************************************************************/
字符串遍历方式2:使用迭代器,迭代器是一个比较通用的对于容器类的遍历方法,在这里可以暂时将迭代器理解为一个字符指针。
/*****************字符串的遍历方式2:迭代器:像指针一样的东西(后面学的其他容器都有这种方式)************/
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("hello");
s1 += ' ';
s1 += "world";
cout << s1 << endl;
//对字符串进行写操作
string::iterator it = s1.begin();
while (it != s1.end())
{
*it += 1;
++it;
}
//对字符串进行读操作
it = s1.begin();
while (it != s1.end())
{
cout << *it << " ";
++it;
}
cout << endl;
//C++11之后,直接使用auto定义迭代器,让编译器推到迭代器的类型
/*
auto rit = s1.rbegin();
while (rit != s1.rend())
{
cout << *rit << endl;
}
*/
return 0;
}
字符串遍历方式3:C++11支持的范围for,其底层也是被编译器替换成迭代器。
/*****************字符串的遍历方式3:C++11支持的范围for****************/
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("hello");
s1 += ' ';
s1 += "world";
cout << s1 << endl;
//底层被编译器替换成迭代器
for (auto ch : s1)
{
cout << ch << " ";
}
return 0;
}
/*****************************************************************/
总结:
string的遍历方式一共有3种, for+[]、 begin()+end()、 范围for。
string遍历时使用最多的还是for+下标 或者 范围for(C++11后才支持),begin()+end()大多数使用在需要使用STL提供的算法操作string时,比如:采用reverse逆置string。
/*************************反向迭代器************************************/
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("hello");
s1 += ' ';
s1 += "world";
cout << s1 << endl;
//倒着遍历字符串:
//对字符串进行写操作
string::reverse_iterator rit = s1.rbegin();
while (rit != s1.rend())
{
*rit += 1;
++rit;
}
//对字符串进行读操作
rit = s1.rbegin();
while (rit != s1.rend())
{
cout << *rit << " ";
++rit;
}
cout << endl;
return 0;
}
/*********************************************************************/
迭代器总结:
- 迭代器从方向上分为:正向迭代器和反向( rbegin()和rend() )迭代器;
- 迭代器从参数属性上分为:普通迭代器和常性(const)迭代器(不能通过迭代器修改数据);
2.3.3 string类对象的容量操作
/***********************字符串容量操作****************************/
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("hello");
string s2("**world**");
cout << s1.size() << endl;
cout << s2.size() << endl;
cout << s1.length() << endl;
cout << s2.length() << endl;
cout << s1.capacity() << endl;
cout << s2.capacity() << endl;
s2 += "11111111";
cout << s2.capacity() << endl;
s1.clear();
cout << s1 <<" " << endl;
cout << s1.capacity() << endl;
return 0;
}
/*****************************************************************/
字符串在进行插入的时候,如果容量满了,他是会自动进行扩容的,按照1.5倍进行扩容。 但是扩容会带来一定的开销(它需要重新拷贝字符串)
/**********************************************************************/
int main()
{
string s;
size_t sz = s.capacity();
cout << "making s grow:\n";
for (int i = 0; i < 100; ++i)
{
s.push_back('c');
if (sz != s.capacity())
{
sz = s.capacity();
cout << "capacity changed: " << sz << '\n';
}
}
return 0;
}
/*****************************************************************/
如何解决呢?这就是reverse() ,一开始便将需要开的内存空间设置好。(一般设置为整数)
/**********************************************************************/
int main()
{
// 构建string时,如果提前已经知道string中大概要放多少个元素,可以提前将string中空间设置好
string s;
s.reserve(100);
size_t sz = s.capacity();
cout << "making s grow:\n";
for (int i = 0; i < 100; ++i)
{
s.push_back('c');
if (sz != s.capacity())
{
sz = s.capacity();
cout << "capacity changed: " << sz << '\n';
}
}
return 0;
}
/*****************************************************************/
利用reserve提高插入数据的效率,避免增容带来的开销。
#include <iostream>
#include <string>
using namespace std;
/**********************************************************************/
int main()
{
string s("hello world");
cout << s.size() << endl; //11
cout << s.capacity() << endl; //15
cout << endl;
s.resize(5);
cout << s.size() << endl; //5 改变了size
cout << s.capacity() << endl; //15
cout << endl;
s.resize(20);
cout << s.size() << endl; //20
cout << s.capacity() << endl; //31 空间扩容了,改变了capacity
return 0;
}
/*****************************************************************/
注意:
- size()与length()方法底层实现原理完全相同,引入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来填充多出的元素空间。注意:resize在改变有效元素个数时,如果是将元素个数增多,可能会改变底层容量的大小(进行扩容),如果是将元素个数减少,底层空间总大小不变。
- reserve(size_t res_arg=0):为string预留空间,不改变有效元素个数,当reserve的参数小于 string的底层空间总大小时,reserve不会改变容量大小。
2.3.4. string类对象的修改操作
/*************************字符串的插入***************************************/
int main()
{
string str;
str.push_back('1'); // 在str后插入字符'1'
str.append("hello"); // 在str后追加一个字符"hello"
str += 'w'; // 在str后追加一个字符'w'
str += "orld"; // 在str后追加一个字符串"orld"
cout << str << endl; //调用的是string重载的operator<<(这里看成的是自定义类型string对象)
cout << str.c_str() << endl; // 直接输出const char *字符串 (这里看成的是基本数据类型)
//获取字符数组的首地址,用C字符串的形式遍历
const char* s = str.c_str();
while (*s)
{
cout << *s ;
++s;
}
cout << endl;
return 0;
}
/*****************************************************************/
/***************************=获取文件的后缀****************************/
int main()
{
// 获取file的后缀
string file("string.cpp");
size_t pos = file.rfind('.');
//rfind 方法从字符串的右端开始查找 '.' 字符的位置。如果找到,返回该字符的位置索引。这里 pos 会被赋值为 6
string suffix(file.substr(pos, file.size() - pos));
//substr 方法从 pos 位置开始截取字符串,一直到字符串末尾。file.size() - pos 计算的是从 pos 位置开始到字符串末尾的字符数为4。在这个例子中,suffix 将被赋值为 ".cpp"。
cout << suffix << endl;
return 0;
}
/*****************************************************************/
/*****************取出url中的域名************************************/
// npos是string里面的一个静态成员变量
// static const size_t npos = -1;
int main()
{
string url("http://www.cplusplus.com/reference/string/string/find/");
cout << url << endl;
size_t start = url.find("://");
if (start == string::npos) //证明没找到
{
cout << "invalid url" << endl;
return;
}
start += 3; //此时start为7
size_t finish = url.find('/', start);
从 start 位置开始查找字符 '/' 在 url 中的位置,并将结果赋值给变量 finish。在这个例子中,finish 将被赋值为 22
string address = url.substr(start, finish - start);
使用 substr 方法从 start 位置开始截取子字符串,长度为 finish - start。在这个例子中,address 将被赋值为 "www.cplusplus.com"。
cout << address << endl;
return 0;
}
/*****************删除url的协议前缀**********************************/
int main()
{
string url("http://www.cplusplus.com/reference/string/string/find/");
pos = url.find("://"); // 查找"://"在字符串url中的位置,并将结果赋值给变量pos
url.erase(0, pos + 3); // 从字符串url中删除从位置0到位置pos + 3的所有字符
cout << url << endl;
return 0;
}
注意:
1. 在string尾部追加字符时,s.push_back(c) / s.append(1, c) / s += 'c'三种的实现方式差不多,一般 情况下string类的+=操作用的比较多,+=操作不仅可以连接单个字符,还可以连接字符串。
2. 对string操作时,如果能够大概预估到放多少字符,可以先通过reserve把空间预留好。
2.3.5 string类非成员函数
2.3.6 vs和g++下string结构的说明
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节。
vs下string的结构:
string总共占28个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字符串的存储空间:
- 当字符串长度小于16时,使用内部固定的字符数组来存放;
- 当字符串长度大于等于16时,从堆上开辟空间;
union _Bxty
{
// storage for small buffer or pointer to larger one
value_type _Buf[_BUF_SIZE];
pointer _Ptr;
char _Alias[_BUF_SIZE]; // to permit aliasing
} _Bx;
这种设计也是有一定道理的,大多数情况下字符串的长度都小于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;
};
指向堆空间的指针,用来存储字符串。
三、练习题
3.1 仅仅反转字母
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]); //c++模板实现好的交换函数
++begin;
--end;
}
return S;
}
3.2 找字符串中第一个只出现一次的字符
class Solution
{
public:
int firstUniqChar(string s)
{
// 使用哈希思想统计每个字符出现的次数
int count[26] = {0};
int size = s.size();
for(int i = 0; i < size; ++i)
count[s[i]-'a'] ++;
// 按照字符次序从前往后找只出现一次的字符
for(int i = 0; i < size; ++i)
if(1 == count[s[i]-'a'])
{
return i;
}
return -1;
}
};
3.3 字符串里面最后一个单词的长度
#include<iostream>
#include<string>
using namespace std;
int main()
{
string s;
// 不要使用cin>>s,因为会它遇到空格就结束了
// while(cin>>s)
while(getline(cin, s))
{
size_t pos = s.rfind(' ');
cout<<s.size()-(pos+1)<<endl;
}
return 0;
}
3.4 验证一个字符串是否是回文
class Solution {
public:
bool isLetterOrNumber(char ch)
{
if((ch >= '0' && ch <= '9')||
(ch >= 'a' && ch <= 'z')||
(ch >= 'A' && ch <= 'Z'))
{
return true;
}
return false;
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;
}
else
{
++begin;
--end;
}
}
return true;
}
};
四、string类的模拟实现
上面已经对string类进行了简单的介绍,只要能够正常使用即可。在面试中,面试官总喜欢面试模拟实现string类,最主要是实现string类的构造、拷贝构造、赋值运算符重载以及析构函数。
4.1 实现一个简单的string =>深浅拷贝问题
/********************************string类的模拟实现********************************/
/*string对象中存储指针,指针指向的数组中存储字符(字符数组),字符数组的最后必须保留'\0'**************************/
//面试题:实现一个简单的string =>深浅拷贝问题
#include <iostream>
using namespace std;
class String
{
public:
/*
String() //无参构造函数
:_str(new char[1])
{
_str[0] = '\0'; //即使是空字符串,C++默认自动会存储一个'\0'
}
String( char* str) //带参构造函数
:_str(new char[strlen(str)+1]) //在堆上开辟存储字符串的空间,C++默认自动会存储一个'\0'
{
strcpy(_str, str); //将代码区(常量区)的字符串拷贝到堆区,这样就可以对字符串进行修改!
}
*/
/*全缺省的默认构造函数*/
String(char* str="") //有一个'\0'
:_str(new char[strlen(str) + 1]) //在堆上开辟存储字符串的空间,C++默认自动会存储一个'\0'
{
strcpy(_str, str); //将代码区(常量区)的字符串拷贝到堆区,这样就可以对字符串进行修改!
}
//自己实现的深拷贝的拷贝构造函数 :String s2(s1)
String(const String& s)
:_str(new char[strlen(s._str) + 1] ) //1、开辟和s1同样大小的空间
{
strcpy(_str, s._str); //2、将s1的数据拷贝到s2
}
//自己实现的深拷贝的赋值运算符重载operator=() s1=s3;
String& operator=(const String& s)
{
if (this != &s) //防止出现自己给自己赋值 s1=s1;
{
char* tmp = new char[strlen(s._str) + 1]; //1、开辟和s3同样大小的空间
strcpy(tmp, s._str); //2、将s3的数据拷贝到tmp所指空间
delete[] _str; //3、释放原来的s1的空间,防止内存泄漏
_str = tmp; //4、修改原来的s1指针的指向
}
return *this; //支持连续赋值
}
char& operator[](size_t i)
{
return _str[i];
}
~String() //析构函数
{
delete[] _str;
_str = nullptr;
}
size_t size()
{
return strlen(_str);
}
private:
char* _str;
};
浅拷贝问题:
说明:如果String类没有显式定义其拷贝构造函数与赋值运算符重载,此时编译器会合成默认的,当用s1构造s2时,编译器会调用默认的拷贝构造。最终导致的问题是,s1、s2共用同一块内存空间,在释放时同一块空间被释放多次而引起程序崩溃,这种拷贝方式,称为浅拷贝。
4.2 浅拷贝
浅拷贝:也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共 享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进项操作时,就会发生发生了访问违规。
就像一个家庭中有两个孩子,但父母只买了一份玩具,两个孩子愿意一块玩,则万事大吉,万一不想分享就你争我夺,玩具损坏。
可以采用深拷贝解决浅拷贝问题,即:每个对象都有一份独立的资源,不要和其他对象共享。父母给每个孩 子都买一份玩具,各自玩各自的就不会有问题了。
4.3 深拷贝
如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显式给出。一般情 况都是按照深拷贝方式提供。
至此,这一讲内容介绍完毕,内容简单,星光不问赶路人,加油吧,感谢阅读,如果对此专栏感兴趣,点赞加关注!