C++ 竞赛入门
string 字符串
把一段文本当数据:追加、查找、截取、插入、删除与遍历。
string 字符串
读完能做什么
- 能创建
string,用下标读出、改写某个字符,并说清下标从 0 开始、最后一个字符的下标是size() - 1。 - 能用
+=、push_back追加,用find查找,用substr截取,用insert、erase插入和删除,并说出每一步之后字符串内容与长度。 - 能用
cin >>读一个单词、用getline读一整行,并解释两者混用时为什么要处理残留的换行。 - 能在
find没找到时先和string::npos比较,再决定要不要使用返回的位置。
为什么需要它
题目的输入不只有数字。姓名、单词、括号序列、地图的一行字符都是文本。用已经学过的字符数组存文本,有三处麻烦:
char a[6] = "hello"; // 片段:长度写死在方括号里,多一个字符就放不下
char b[6];
strcpy(b, a); // 复制要用函数,不能写 b = a
if (strcmp(a, b) == 0) { // 比较内容也要用函数,不能写 a == b
cout << "same\n";
}
这是一段片段(省略了头文件与 main),它说明字符数组要自己管容量、自己记结束符、复制和比较都得调函数。string 把这些都包好了:长度自动增长,= 是复制,== 比较内容,+ 直接拼接。
和快速入门的分工。 字符数组与字符串那一节讲的是概念——char 和一段文本有什么区别、C 风格字符串和 std::string 各是什么、什么时候用哪个;C++ string那一节讲了定义、读入、输出、size()、下标和 getline 的入门用法。本页接着往下讲操作:怎么追加、查找、截取、插入、删除和遍历,以及这些操作的位置参数和长度参数分别是什么意思。
它是什么
一句话:string 是标准库提供的文本类型,它把一段字符当成一个可以整体操作的数据——能像数组一样按下标读写单个字符,也能一次追加、查找、截取一整段。
直观模型是一排连续的格子,每个格子放一个字符,格子从左到右编号 0、1、2……以 "banana" 为例:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 字符 | b | a | n | a | n | a |
下标从 0 开始。 第 i 个字符写作 s[i],最后一个字符的下标是 s.size() - 1。对 "banana" 来说合法下标是 0 到 5,s[6] 已经越界。下标既能读也能改:s[0] = 'B' 会把首字母换成大写。
size() 是字符个数。 "banana" 的 size() 是 6。空格也算一个字符,"hello world" 的 size() 是 11。size() 返回的是无符号整数类型(string::size_type,见易错点一节),和 int 比较时要注意转换。
查找与 string::npos。 s.find("na") 返回子串第一次出现的位置(下标);如果整段字符串里都找不到,返回一个特殊值 string::npos,表示“没有这个位置”。所以拿到返回值后必须先和 string::npos 比较,确认找到了,才能拿它去做截取或删除。
位置和长度是两件事。 截取写作 s.substr(pos, len):从下标 pos 开始,取 len 个字符。删除写作 s.erase(pos, len):从下标 pos 开始,删掉 len 个字符。pos 回答“从哪儿开始”,len 回答“要几个”,不要写反。
cin >> 与 getline。 cin >> s 从当前位置开始读,遇到空格、制表符或换行就停,所以一次只读到一个单词。getline(cin, s) 一直读到换行为止,把空格也保留在字符串里,所以能读一整行。读入的详细对比见后面的操作小节。
适用场景:保存和处理文本,尤其是长度事先不知道、需要查找或截取片段的场合。使用时要 #include <string>,本页示例用 #include <bits/stdc++.h> 一次包含全部标准头文件(这两句的解释在什么是 STL)。
最小可运行示例
数据写在代码里,不需要输入:
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "banana"; // 6 个字符,下标 0 到 5
cout << "s = " << s << '\n';
cout << "size = " << s.size() << '\n';
for (int i = 0; i < (int)s.size(); i++) {
cout << "s[" << i << "] = " << s[i] << '\n';
}
cout << "s.front() = " << s.front() << ", s.back() = " << s.back() << '\n';
s[0] = 'B'; // 下标可以改写字符
cout << "改首字母后 s = " << s << '\n';
return 0;
}
输出:
s = banana
size = 6
s[0] = b
s[1] = a
s[2] = n
s[3] = a
s[4] = n
s[5] = a
s.front() = b, s.back() = a
改首字母后 s = Banana
逐行看:
string s = "banana";创建string并把 6 个字符按顺序放进去。s.size()返回字符个数 6;循环条件写成(int)s.size()是为了把它转成int,与int i比较时不出现“有符号与无符号比较”的警告。s[i]用下标取字符;输出里s[0] = b到s[5] = a与上面的格子表一一对应。s.front()是第一个字符,s.back()是最后一个字符。s[0] = 'B'把下标 0 上的字符换成大写'B',长度仍是 6——下标是改写已有字符,不是增加字符。
一步步看变化
下面用同一份数据 "banana" 依次做三步,重点是 find、substr、erase 的参数含义:
| 步骤 | 操作 | 操作前 | 操作后 | size() |
|---|---|---|---|---|
| 0 | 初始 | — | "banana" |
6 |
| 1 | s.find("na") |
"banana" |
返回 2(字符串不变) | 6 |
| 2 | s.substr(2, 2) |
"banana" |
返回 "na"(字符串不变) |
6 |
| 3 | s.erase(2, 2) |
"banana" |
"bana" |
4 |
三步合起来的完整程序:
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "banana";
size_t pos = s.find("na"); // 第一次出现的位置
cout << "find(\"na\") = " << pos << '\n';
string part = s.substr(pos, 2); // 从 pos 开始取 2 个字符
cout << "substr(pos, 2) = " << part << '\n';
s.erase(pos, 2); // 从 pos 开始删除 2 个字符
cout << "erase(pos, 2) 后 s = " << s << '\n';
cout << "s.size() = " << s.size() << '\n';
return 0;
}
输出:
find("na") = 2
substr(pos, 2) = na
erase(pos, 2) 后 s = bana
s.size() = 4
- 第 1 行:从下标 0 开始找,
b a n a n a里第一次出现na是在下标 2(n在下标 2、a在下标 3)。find只报告位置,不改字符串。 - 第 2 行:
substr(2, 2)从下标 2 开始取 2 个字符,得到na。截取是“复制一段出来”,原来的s还是banana。 - 第 3 行:
erase(2, 2)从下标 2 开始删掉 2 个字符,也就是删掉下标 2、3 上的n、a,后面的字符整体左移,得到bana。 - 第 4 行:长度从 6 变成 4,与删掉 2 个字符一致。
- 程序里用
size_t pos = s.find("na")存位置:size_t是标准库里的无符号整数类型,专门用来表示“长度、位置、下标”,string::size_type就是它的别名。以后见到size_t,就把它当成“装位置和长度的无符号整数”即可。
第 3 步前后可以这样看:
| 时刻 | 下标 0 | 下标 1 | 下标 2 | 下标 3 | 下标 4 | 下标 5 |
|---|---|---|---|---|---|---|
| 删除前 | b | a | n | a | n | a |
| 删除下标 2、3 后 | b | a | n | a | — | — |
逐项扩展核心操作
追加与拼接:+=、push_back、+
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "hello";
cout << "初始 s = " << s << ", size = " << s.size() << '\n';
s += " world"; // 追加,直接改 s
cout << "s += \" world\" 后 s = " << s << ", size = " << s.size() << '\n';
s.push_back('!'); // 末尾追加一个字符
cout << "push_back('!') 后 s = " << s << ", size = " << s.size() << '\n';
s.insert(5, ","); // 在下标 5 之前插入
cout << "insert(5, \",\") 后 s = " << s << ", size = " << s.size() << '\n';
s.erase(0, 5); // 从下标 0 开始删除 5 个字符
cout << "erase(0, 5) 后 s = " << s << ", size = " << s.size() << '\n';
return 0;
}
输出:
初始 s = hello, size = 5
s += " world" 后 s = hello world, size = 11
push_back('!') 后 s = hello world!, size = 12
insert(5, ",") 后 s = hello, world!, size = 13
erase(0, 5) 后 s = , world!, size = 8
| 操作 | 参数含义 | 状态变化 |
|---|---|---|
s += " world" |
追加一整段 | hello → hello world,长度 5 → 11 |
s.push_back('!') |
追加一个字符 | hello world → hello world!,长度 11 → 12 |
s.insert(5, ",") |
在下标 5 之前插入 | hello world! → hello, world!,长度 12 → 13 |
s.erase(0, 5) |
从下标 0 起删 5 个字符 | hello, world! → , world!,长度 13 → 8 |
+= 和 push_back 都在末尾增加字符:+= 接一整段字符串,push_back 接一个字符。insert(pos, str) 插到位置 pos 之前,注意它和 erase 一样是“位置”参数,不是“在第几个字符后面”。
+ 号也能拼接,但它产生一个新字符串,原来的两个都不变:
#include <bits/stdc++.h>
using namespace std;
int main() {
string a = "hello";
string b = "world";
string c = a + " " + b; // 拼接产生新字符串,a、b 不变
cout << "c = " << c << '\n';
cout << "a 还是 " << a << ",b 还是 " << b << '\n';
string x = "abc";
string y = "abd";
cout << "a == b? " << (a == b) << '\n';
cout << "a < b? " << (a < b) << '\n'; // 字典序:比第一个不同的字符
cout << "x < y? " << (x < y) << '\n';
string stars(3, '*'); // 3 个 '*' 组成的新字符串
cout << "stars = " << stars << '\n';
return 0;
}
输出:
c = hello world
a 还是 hello,b 还是 world
a == b? 0
a < b? 1
x < y? 1
stars = ***
a + " " + b得到hello world,a和b本身没被改动;想改动其中一个就写a += " world"。==比较的是内容,a == b为假所以打印 0(bool输出默认是 0/1)。<按字典序比较:从左往右比第一个不同的字符,x、y前两个字符相同,第三个c<d,所以x < y为真。string stars(3, '*');是“3 个字符'*'的副本”,得到***。这里第二个参数是字符而不是数字,写string(3, 0)得到的是 3 个值为 0 的字符(即'\0'),不是“3 个数字 0”,打印出来什么也看不见。
查找:find 与 string::npos
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "banana";
size_t p1 = s.find("an");
cout << "find(\"an\") = " << p1 << '\n';
size_t p2 = s.find("an", 2); // 从下标 2 开始往后找
cout << "find(\"an\", 2) = " << p2 << '\n';
size_t p3 = s.find("xyz");
cout << "p3 == string::npos? " << (p3 == string::npos) << '\n';
if (p3 == string::npos) {
cout << "没找到 xyz,不能拿 p3 去 substr\n";
} else {
cout << s.substr(p3) << '\n';
}
return 0;
}
输出:
find("an") = 1
find("an", 2) = 3
p3 == string::npos? 1
没找到 xyz,不能拿 p3 去 substr
| 调用 | 结果 | 说明 |
|---|---|---|
s.find("an") |
1 | 从下标 0 开始找,an 第一次出现在下标 1(a 在 1、n 在 2) |
s.find("an", 2) |
3 | 从下标 2 开始找,跳过下标 1 那一次,命中下标 3 |
s.find("xyz") |
string::npos |
整段都没有,返回特殊值,与 npos 比较为真 |
find 返回第一次出现的下标;想找下一次,就把起点往后挪,写成 s.find(目标, 起点)。第二个参数省略时起点是 0。
string::npos 是一个表示“没有找到”的特殊值,类型和 size() 一样(本机实测它的值是 18446744073709551615,也就是无符号整数能表示的最大值)。必须比较后再用:这个位置远远超出字符串长度,拿它去 substr 或 erase 会直接抛出 out_of_range 异常,在竞赛代码里就是一个没被捕获的异常,程序当场结束。所以固定写法是先判断:
size_t pos = s.find("xyz");
if (pos != string::npos) {
cout << s.substr(pos) << '\n'; // 只有找到时才用这个位置
}
这是片段,省略了头文件与 main;s.substr(pos) 只给一个参数时,表示“从 pos 一直取到末尾”。
截取:substr(pos, len)
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "hello world";
cout << "s.substr(0, 5) = " << s.substr(0, 5) << '\n'; // 从 0 取 5 个
cout << "s.substr(6, 5) = " << s.substr(6, 5) << '\n'; // 从 6 取 5 个
cout << "s.substr(6) = " << s.substr(6) << '\n'; // 从 6 取到末尾
cout << "s 本身没变:" << s << '\n';
return 0;
}
输出:
s.substr(0, 5) = hello
s.substr(6, 5) = world
s.substr(6) = world
s 本身没变:hello world
substr 的第一个参数是起始下标,第二个参数是取几个字符。省掉第二个参数表示一直取到末尾。它返回一个新字符串,原来那个不变。取的长度超过剩余字符数时,就取到末尾为止,不会报错。
插入与删除:insert、erase
insert(pos, str) 把 str 插到下标 pos 之前,erase(pos, len) 从下标 pos 开始删 len 个字符:
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "world";
s.insert(0, "hello "); // 插到最前面
cout << "insert(0, \"hello \") 后:" << s << '\n';
s.insert(5, "!"); // 插到下标 5 之前,也就是 hello 后面
cout << "insert(5, \"!\") 后:" << s << '\n';
s.erase(5, 1); // 删掉刚插入的那个字符
cout << "erase(5, 1) 后:" << s << '\n';
s.erase(0, 6); // 删掉开头的 hello 和空格
cout << "erase(0, 6) 后:" << s << '\n';
return 0;
}
输出:
insert(0, "hello ") 后:hello world
insert(5, "!") 后:hello! world
erase(5, 1) 后:hello world
erase(0, 6) 后:world
| 操作 | 位置含义 | 状态变化 |
|---|---|---|
s.insert(0, "hello ") |
插到下标 0 之前,即最前面 | world → hello world |
s.insert(5, "!") |
插到下标 5 之前,即 hello 之后 |
hello world → hello! world |
s.erase(5, 1) |
从下标 5 起删 1 个字符 | hello! world → hello world |
s.erase(0, 6) |
从下标 0 起删 6 个字符 | hello world → world |
insert 和 erase 都是在中间动手,后面的字符要整体搬动,所以它们比尾部追加慢(见复杂度说明)。
遍历:下标与范围 for
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "banana";
cout << "下标遍历:";
for (int i = 0; i < (int)s.size(); i++) cout << s[i] << ' ';
cout << '\n';
cout << "范围 for:";
for (char c : s) cout << c << ' ';
cout << '\n';
int cnt = 0;
for (char c : s) {
if (c == 'a') cnt++;
}
cout << "字母 a 出现 " << cnt << " 次\n";
return 0;
}
输出:
下标遍历:b a n a n a
范围 for:b a n a n a
字母 a 出现 3 次
两种写法效果一样:下标遍历能拿到位置,范围 for 写起来更短。for (char c : s) 的意思是“把 s 里的字符逐个取出来,每次放进 c”,c 是副本,改它不会改到字符串;想改到原字符串,把 c 写成引用 char& c(引用在 vector 动态数组里解释过)。
统计时用的是字符比较 c == 'a':单引号里是一个字符,双引号里是字符串,两者不能混用。
读入:cin >> 与 getline
先看 cin >> 读单词:输入 hello world,它只把 hello 读走,空格和后面的内容留在输入流里。
#include <bits/stdc++.h>
using namespace std;
int main() {
string word;
cin >> word; // 遇到空白就停:只读到一个单词
string rest;
getline(cin, rest); // 这一行剩下的部分(包含空格)
cout << "cin >> 得到:[" << word << "]\n";
cout << "这一行剩下的部分:[" << rest << "]\n";
return 0;
}
输入:
hello world
输出:
cin >> 得到:[hello]
这一行剩下的部分:[ world]
输出里的方括号是特意加的,用来显示字符串的边界:cin >> 只拿到 hello,getline 接着把这一行剩下的 world(开头有一个空格)读完。
要读整行就用 getline(cin, s),它读到换行为止,空格原样保留:
#include <bits/stdc++.h>
using namespace std;
int main() {
string line;
getline(cin, line); // 读到换行为止,空格保留在字符串里
cout << "整行:[" << line << "]\n";
cout << "长度:" << line.size() << '\n';
return 0;
}
输入:
hello world
输出:
整行:[hello world]
长度:11
长度是 11:5 个字母 + 1 个空格 + 5 个字母。空格算一个字符,这也是 cin >> 和 getline 最直观的区别。
混用时要处理残留的换行。 先 cin >> n 读数字,再用 getline 读一行,会读到一个空串:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n; // 只把 3 读走,第一行末尾的换行留在输入流里
string line;
getline(cin, line); // 立刻读到的就是那个残留换行 → 空串
cout << "直接 getline 读到:[" << line << "],长度 " << line.size() << '\n';
string line2;
getline(cin, line2); // 这一次才读到真正的第二行
cout << "再 getline 一次读到:[" << line2 << "],长度 " << line2.size() << '\n';
return 0;
}
输入:
3
hello world
输出:
直接 getline 读到:[],长度 0
再 getline 一次读到:[hello world],长度 11
原因:cin >> n 读到 3 就停,第一行末尾的换行没有被它拿走。紧接着的 getline 看到这个换行,就认为“这一行已经结束”,于是返回空串。解决办法是在两者之间显式跳过这个换行:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
cin.ignore(); // 吃掉残留的换行,下一次 getline 从新行开始
string line;
getline(cin, line);
cout << "n = " << n << ",读到的整行:[" << line << "]\n";
return 0;
}
输入:
3
hello world
输出:
n = 3,读到的整行:[hello world]
cin.ignore() 丢掉输入流里的一个字符(这里正好是那个换行),之后 getline 就从第二行开头开始读。规则可以记成一句话:同一份输入里,cin >> 之后接 getline,中间先丢掉这一行的残余内容——cin.ignore() 只丢一个字符,若数字后面还可能跟空格,用 cin.ignore(1e9, '\n') 跳到行尾,或者直接写 getline(cin >> ws, line)。
什么时候选它、哪里容易错
下标只能改已有字符,不能用来“长出”新字符。 string s; 是空串,此时 s[0] = 'c' 越界,结果不确定——空串没有下标 0。赋值之后 s = "new string str"; 长度变成 14,这时 s[1] = 'c' 才是合法的。想增加字符用 += 或 push_back,想指定初始长度用 string(n, c)。判断能不能用某个下标,就看它是否小于 size()。
find 的结果必须先和 string::npos 比较。 npos 不是合法下标:把它交给 substr 或 erase 会抛出 out_of_range 异常,用 s[pos] 去读则是越界访问、结果不确定。固定写法是 if (pos != string::npos) { ... }。
位置和长度不要写反。 substr(pos, len)、erase(pos, len) 的第一个参数都是从哪开始,第二个参数是多少个。注意 erase 省略第二个参数时含义会变:s.erase(pos) 是“从 pos 一直删到末尾”,不是“只删一个字符”。想只删一个字符就写 s.erase(pos, 1)。
cin >> 和 getline 的分工要记牢。 只想要一个不含空格的单词用 cin >>;要一整行(可能含空格)用 getline。混用时记得 cin.ignore() 处理残留换行,否则会读到一个莫名其妙的空串。
size() 是无符号类型。 s.size() 的类型是 string::size_type,它是一种无符号整数类型,无论字符串多长都能装下它的长度,也不会是负数。正因为它是无符号的,和 int 变量比较时要小心:如果 int 是负数,比较时会被转成很大的无符号数,判断就会出错,所以循环里习惯写成 i < (int)s.size()。判断空串直接用 s.empty()。
复杂度。 find 要顺着看一遍,是 O(n);substr 要复制一段出来,代价与取的长度成正比;在尾部 +=、push_back 是摊还常数时间;在中间 insert、erase 要把后面的字符整体搬动,是 O(n)。
操作速查表
| 操作 | 作用 | 备注 |
|---|---|---|
string s = "abc"; |
用字面值创建 | 下标从 0 开始 |
string s(n, c); |
创建 n 个字符 c |
第二个参数是字符,不是数字 |
s.size() |
字符个数 | 类型是无符号整数 |
s.empty() |
是否为空串 | 打印成 0/1 |
s[i] |
下标访问 | 只能改已有字符,不检查越界 |
s.front() / s.back() |
首字符 / 末字符 | 空串不能调用 |
s += t / s.push_back(c) |
尾部追加字符串 / 字符 | 摊还 O(1) |
s1 + s2 |
拼接出新字符串 | 原字符串不变 |
s.find(t) |
t 第一次出现的下标 |
没找到返回 string::npos |
s.find(t, pos) |
从下标 pos 开始找 |
用来找下一次出现 |
s.substr(pos, len) |
从 pos 取 len 个字符 |
返回新字符串,s 不变 |
s.insert(pos, t) |
在下标 pos 之前插入 |
后面字符后移,O(n) |
s.erase(pos, len) |
从 pos 起删 len 个字符 |
后面字符前移,O(n) |
s.erase(pos) |
从 pos 起删到末尾 |
不是“只删一个字符” |
getline(cin, s) |
读一整行(含空格) | 与 cin >> 混用前先 cin.ignore() |
s1 == s2 / s1 < s2 |
比较内容 / 字典序 | 不像字符数组那样需要函数 |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),请先手推出输出,再运行核对:
string s = "banana";
s.erase(2, 2); // 从下标 2 开始删 2 个字符
cout << s << ' ' << s.size() << '\n';
第 2 题:修改代码
下面这段片段把 "nan" 截取出来。请改成查找 "xyz",要求没找到时不调用 substr,而是输出 not found:
string s = "banana";
size_t p = s.find("nan");
string part = s.substr(p, 3);
cout << part << '\n';
验证条件:先说出删掉的是哪两个字符、剩下的字符串和长度各是多少,再运行核对。提示:find 没找到时返回的不是一个合法下标。
参考结果与解释
第 1 题:输出是 bana 4。erase(2, 2) 从下标 2 开始删掉 2 个字符,也就是下标 2、3 上的 n、a;后面的字符整体左移,得到 bana,长度从 6 变成 4。注意第二个参数是个数:写成 erase(2) 会从下标 2 一直删到末尾,得到 ba。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "banana";
s.erase(2, 2); // 从下标 2 开始删 2 个字符
cout << s << ' ' << s.size() << '\n';
return 0;
}
输出:
bana 4
第 2 题:find("nan") 得到 2(下标 2、3、4 正好是 nan),原程序输出 nan;find("xyz") 返回 string::npos,必须先判断再截取。
#include <bits/stdc++.h>
using namespace std;
int main() {
string s = "banana";
size_t p = s.find("xyz");
if (p != string::npos) {
string part = s.substr(p, 3);
cout << part << '\n';
} else {
cout << "not found\n";
}
return 0;
}
输出:
not found
注意不能把 p 直接交给 substr:npos 远大于字符串长度,会抛 out_of_range 异常,程序直接结束而不是输出 not found。
立即练习
| 难度 | 题目 | 题解 | 训练目标 |
|---|---|---|---|
| 入门 | 洛谷 P5015:[NOIP 2018 普及组] 标题统计 | 题解 | 读入一整行,统计其中除空格、换行之外的字符个数:练习 getline 读整行与 size()、逐字符遍历。 |
| 普及- | 洛谷 P1308:[NOIP 2011 普及组] 统计单词数 | 题解 | 在整行文本里找另一个单词出现的次数与第一次出现的位置:练习 find 与“找不到要和 string::npos 比较”,并处理大小写。注意题目要求整词匹配(Ottoman 里的 to 不算),find 找到后还要检查左右两边是不是字母。 |
两道题都只需要字符、循环和本页讲过的 string 操作。做完后请说明:这两题为什么必须用 getline 读整行,而不能用 cin >>?