总述
基本概念
- (字符)串(string)是指由零个或者多个元素(字符)组成的有限的序列。序列中所含的字符的个数称为该串的长度(教材中讨论的都是基于
ASCII
编码的字符串的长度,这里不讨论Unicode
、GBK
等多字节宽字符的情况)。 含有零个字符的串称为空串。 - 通常将字符串用双引号括起来表示,如"a1a2a3...an",与大多数编程语言的习惯一致。也经常将字符串看作数组,也通常使用数组的表示形式A[1..n],对于其中的第i个字符,可以用
ai 或A[i]表示。 - 串相等的定义:当且仅当两个串长度相等,且各个对应位置上的元素的个数相同
- 子串(substring)的定义:一个串中任意个连续字符组成的序列。注意不要与子序列(sub-sequence)混淆:子序列是可以不连续的,而子串必须是源串的连续的部分。
对下面讨论的内容的一些说明
C++在string
头文件中内置了一个std::string
类型,可以用来表示串。下面的实现实际上是一个简易的string
类型。其实对于string
的实现本身不是很复杂,更多的涉及到一些C++语言本身的内容,权且当作对C++的复习。
另外,链串在实际中用处很少,因此没有讨论其实际实现。教材上讨论的简化版——每个结点只存放一个字符,实际处理起来与顺序串无异。
串的基本操作
因为作为一种常用数据类型,使用C++语言描述必然去实现一个基本的string
类,因此考虑到运算符重载的问题,多了一项对应运算符。对于C语言来说,则需要使用一系列库函数来完成(类似于教材中给的一些函数)。
基本操作 | 对应运算符 | 描述 |
---|---|---|
assign(s) | operator= | 返回由CStyle String s构成的字符串 |
copy(s1, s2) | operator= | 将串s1 拷贝到s2 |
equal(s1, s2) | operator== | 判断s1 和s2 是否相等 |
length(s) | - | 返回s 的长度 |
concat(s1, s2) | operator+ | 将字符串s1 和s2 连接 |
substr(s, i, l) | - | 返回s 从i 开始长度为l 的子串 |
insert(s1, i, s2) | - | 返回将s2 插入在s1 的i 个位置 |
remove(s, i, l) | - | 返回串s 删除从i 到l 的字符的新串 |
replace(s1, i, j, s2) | - | 返回将串s1 从i 到j 的所有字符用串s2 替换的新串 |
find查找操作涉及了4.2节的模式匹配问题,暂且不讨论。
顺序串的实现
类定义
顺序串的存储结构基于字符类型的顺序表,在C/C++语言中的C-Style String也就是char[]
。除了length
域。通常情况下,还应该设置capacity域,用来处理扩容问题。实际上基于STL的std::vector
的经验,2倍扩容法是比较经济高效的,能够保证O(1)的摊还时间;同时拥有一定的富余空间可以避免一些没有必要的内存分配,有利于提高性能。这里为了简化讨论,只讨论串的基本操作,不考虑性能优化问题。
部分重载的运算符没有实现。其中最主要的赋值运算符operator=
与拷贝构造函数的实现较为类似。
* 字符串类
*/
class SeqString
{
private:
char * _data; //存放实际数据的指针
int _length; //存放字符串的实际长度
public:
/**
* @brief 创建一个空的字符串对象
*/
SeqString() :_length(0), _data(nullptr){}
/**
* @brief 根据已有C风格字符串生成一个字符串对象
* @param const char * src 源字符串
*/
SeqString(const char * src);
/**
* @brief 拷贝构造函数,根据已有的字符串对象创建一个新的字符串对象
* @param const SeqString & src 源字符串
*/
SeqString(const SeqString & src);
/**
* @brief 析构函数
*/
~SeqString(){ if (_data) delete[] _data; }
/**
* @brief 判断字符串是否为空串
* @return bool 为空则true,反之false
*/
bool empty() const;
/**
* @brief 返回字符串的长度
* @return int 字符串的实际长度
*/
int length() const;
/**
* @brief 返回字符串在指定位置的字符
* @param int i 指定的位置
* @return char & 返回指定位置的字符
*/
char & at(int i);
/**
* @brief 返回字符串在指定位置的字符
* @param int i 指定的位置
* @return char & 返回指定位置的字符
*/
char & at(int i) const;
/**
* @brief 返回字符串在指定位置的字符
* @param int i 指定的位置
* @return char & 返回指定位置的字符
*/
char & operator[](int i);
/**
* @brief 返回字符串在指定位置的字符
* @param int i 指定的位置
* @return char & 返回指定位置的字符
*/
char & operator[](int i) const;
/**
* @brief 将指定的字符串连接在当前字符串的后面
* @param const SeqString & str 需要连接的字符串
* @return SeqString & 连接后的字符串
*/
SeqString concat(const SeqString & str);
/**
* @brief 截取字符串
* @param int start 开始的位置
* @param int len 截取的长度
* @return SeqString 返回截取的字符串
*/
SeqString substr(int start, int len);
/**
* @brief 替换字符串
* @param int start 开始的位置
* @param int len 截取的长度
* @param SeqString & target 需要替换的新字符串
* @return SeqString & 返回自身经过替换后的字符串
*/
SeqString & replace(int start, int len, const SeqString & target);
/**
* @brief 流输出操作符重载
* @param std::ostream & os 输出流
* @param const SeqString & str 需要输出的字符串
* @param std::ostream & 输出流
*/
friend std::ostream & operator<<(std::ostream & os, const SeqString & str){
return os << str._data;
}
};
/**
* @brief 判断两个字符串是否相等
* @param const SeqString & s1 需要比较的字符串1
* @param const SeqString & s2 需要比较的字符串2
* @return bool 字符串是否相等
*/
bool SeqString_equal(const SeqString & s1, const SeqString & s2);
/**
* @brief 比较两个字符串的大小
* @param const SeqString & s1 需要比较的字符串1
* @param const SeqString & s2 需要比较的字符串2
* @return int 1 字符串s1>s2,0 s1 == s2, -1 s1 < s2
*/
int SeqString_compare(const SeqString & s1, const SeqString & s2);
/**
* @brief 将两个字符串连接
* @param const SeqString & s1 需要比较的字符串1
* @param const SeqString & s2 需要比较的字符串2
* @return int 1 字符串s1>s2,0 s1 == s2, -1 s1 < s2
*/
SeqString operator+(const SeqString & s1, const SeqString & s2);
构造函数
除了有根据C风格数组创建的构造函数之外,string
也需要具有一个拷贝构造函数,因为string的数据是动态分配的,不能使用编译器默认生成的拷贝构造函数(浅拷贝)。实际上如果不希望使用拷贝构造函数,应该将其显式声明为private
以提供编译器检查的方便。string
类在C++面试中经常被考察,也是因为其拷贝构造函数和operator=
的原因。
教材中
string
的实现方式,因为是静态分配的内存空间,因此不必使用拷贝构造函数。
SeqString::SeqString(const char * src){
_length = strlen(src);
_data = new char[_length + 1];
assert(_data);
strcpy(_data, src);
}
SeqString::SeqString(const SeqString & src){
if(src.empty()){
_length = 0;
_data = nullptr;
}else{
_length = src.length();
_data = new char[_length + 1];
strcpy(_data, src._data);
}
}
获取字符串长度、判空
bool SeqString::empty() const{
return _length == 0;
}
int SeqString::length() const{
return _length;
}
字符串连接
字符串的连接,即将一个字符串添加到另一个字符串之后,因此直接将两个字符串依次拷贝进一个临时的C风格字符串,然后调用其构造函数即可。需要注意长度也要累加。
这里使用了智能指针std::unique_ptr
,用来存放拼接后的临时C风格字符串,因为构造函数只能根据C风格字符串创建。而C风格字符串如果使用普通的new
进行动态内存分配,只会导致在return
生成对象时,后面的delete[]
不能被执行,智能指针则可以避免这个问题。std::shared_ptr
定义数组时,需要指明delete[]
而不是使用默认的delete
,因此直接使用unique_ptr
较为方便。
智能指针需要C++11标准的支持。
SeqString SeqString::concat(const SeqString & str){
if(str.empty()){
//第二个串空,则直接使用拷贝构造函数返回第一个串的副本
return SeqString(*this);
}
std::unique_ptr<char[]> newdata(new char[this->length() + str.length() + 1]);
//智能指针,需要#include <memory>
if(!this->empty()){
strcpy(newdata.get(), this->_data); //源串不空则复制
}
strcpy(newdata.get() + this->length(), str._data);
return SeqString(newdata.get());
}
字符串截取
字符串的截取,只需要将源字符串指定位置的字符序列拿出来,重新构造一个字符串对象就可以了。
有必要提及的是,根据《算法 第四版》描述,在Java的标准库(JDK <= 1.6
)实现中,它并不产生一个实际的子串数据,而是子串对象和源串共享原始数据,只不过指针指向不同,一个指向源字符串的首,另一个指向子串的开始位置。因此在这种方法,时间是O(1)的。这样效率很好,但是容易出现在GC的过程中无法回收导致内存泄漏的问题。在JDK 1.7
及以后版本中,通常会使用复制子串的办法,在面对各种可能的极端情况下牺牲了效率追求了健壮性和稳定性。至于不复制数据直接使用指针的,在C/C++处理特定字符串的过程中也是一种思路,代价就是手工管理内存更是灾难。
SeqString SeqString::substr(int start, int len){
//检查参数合法性
assert(start >= 0 && start < _length);
assert(len > 0 && start + len < _length);
std::unique_ptr<char[]> tmp(new char[len + 1]);
//智能指针,需要#include <memory>
int i;
for(i = 0; i < len; i++){
tmp[i] = _data[start + i];
}
tmp[i] = '\0';
return SeqString(tmp.get());
}
字符串替换
字符串替换操作就是将指定位置的字符序列替换为需要的字符串的字符序列,这里使用的方法依然是开辟新的空间,然后释放旧有空间。因为需要永久保存数据,因此不使用智能指针。使用i
表示从源中读取数据的下标,而j
表示写入数据的下标。
这里replace
作为成员函数的考虑是基于替换操作的对象就是其本身,个人觉得生成一个新的字符串不太符合替换的语义(拿不准,希望能够进一步讨论。)。因此返回的就是替换后的引用。
SeqString & SeqString::replace(int start, int len, const SeqString & target){
//检查参数合法性
assert(start >= 0 && start < _length);
assert(len > 0 && start + len < _length);
int new_length = this->length() - (len - start) + target.length();
char * tmp = new char[new_length + 1];
int i, j = 0;
for(i = 0; i < start; i++){
tmp[j++] = this->_data[i];
}
for(i = 0; i < target.length(); i++){
tmp[j++] = target[i];
}
for(i = start + len; i < this->length(); i++){
tmp[j++] = this->_data[i];
}
tmp[j] = '\0';
this->_length = new_length;
delete [] this->_data;
this->_data = tmp;
return *this;
}
字符串比较
字符串的比较是基于每一个字符的ASCII码的。因此逐个字符进行比较,直到出现能比较出大小的(两个字符不一样)为止。如果公共长度都比较结束而没有比较出两个字符的大小的(也就是说一个是另一个的前缀),则短的比长的小。
因为operator[]
是公共接口,故没必要使用友元函数破坏封装性。
int SeqString_compare(const SeqString & s1, const SeqString & s2){
int i;
int common_length = std::min(s1.length(), s2.length());
for(i = 0; i < common_length; i++){
if(s1[i] < s2[i]){
return -1;
}else if(s1[i] > s2[i]){
return 1;
}
}
if(s1.length() > common_length){
return -1;
}else if(s1.length() < common_length){
return 1;
}else{
return 0;
}
}
链串
链串是基于链表的字符串的存储。链表的优点是串的插入和删除较快,而不能随机存储。实际上对字符串的操作,基于指定下标的随机操作非常重要,因此链串实际的用途并不多。 链串的基本操作依然可以转化为线性表的基本操作。尤其对于教材上提及的,每个结点只放一个字符的,完全可以将上面的顺序串的每一个下标看作是每一个链表节点,直接使用链表的一些操作(插入、删除、查找、遍历等)即可完成相关问题。Summary
- 串也是一种特殊的线性表(数据类型为字符类型
char
) - 串作为线性表也有顺序存储结构和链式存储结构。
- 大多数编程语言内置了string的类型,C是直接使用
char[]
(C++也可以),对于C++来说实现一个string尤其需要处理内存分配问题,以及何时返回对象、何时返回引用的问题。