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 >>?