← 目录 第 8 / 60 节 全部 60 节

08 · 字符与字符串基础

把 char 当整数、把 string 当数组:掌握 ASCII 三组连续区间、getline 与 cin.ignore 的配合、cctype 工具箱,以及范围 for 中引用与非引用的差别。
⭐ 已学范围入门预计 55 分钟ASCIIgetlinecctype
本节出现的新函数 / 新语法(先看懂再用)

char 不是字母,是一个 0~127 的整数。'0' 是 48,'A' 是 65,'a' 是 97。字符题最后只落到两件事:用整数差值把字符映射成下标,用一次遍历扫完整个串。

例题 · 手机按键洛谷 P1765普及-去洛谷做这题 ↗

手机键盘的字母分布:2:abc 3:def 4:ghi 5:jkl 6:mno 7:pqrs 8:tuv 9:wxyz。在同一个键上按第 n 下,得到这个键上的第 n 个字母(7 键连按 4 下得 s);空格按 0 键一下。

给定一行只含小写字母和空格的文本(长度不超过 200),求打出它最少要按多少下键。

输入格式:一行文本。输出格式:一行一个整数。

输入: hello world 输出: 25

先记三组连续区间。区间内部编码连续,这是所有字符题的地基。

区间ASCII 码长度取序号用途
'0' ~ '9'48 ~ 5710c - '0'数字字符 → 0~9
'A' ~ 'Z'65 ~ 9026c - 'A'大写字母 → 0~25
'a' ~ 'z'97 ~ 12226c - 'a'小写字母 → 0~25
'a' - 'A'32—c ^ 32大小写互换
字符题只有两种操作:把字符变成下标,把下标变成字符。

两条推论:① 区间内第 k 个字符 = 首字符 + k,所以 c - 'a' 直接就是 0~25 的下标;② 区间之间不连续——'9'+1 是 ':','Z'+1 是 '['。判断一个字符属于哪一组,两头都要写。

新函数拆解 · getline(读一整行)
std::getline(std::cin, s);
组成含义 / 格式要求
std::cin从标准输入流读,换成 ifstream 就是读文件。
s整行内容存进来,不含结尾的 '\n'。
停在哪遇 '\n' 结束,并把它从缓冲区取走。
返回值流本身,所以能写 while (getline(cin, s))。
最小可运行示例
示例
string s;
getline(cin, s);        // 输入 hello world → 11 个字符全进来
cout << s.size();       // 11

cin >> s;               // 只读到第一个词,停在空格
getline(cin, s);        // 读到的是剩下的 " world"
注意

cin >> s 遇空格就停,含空格的整行只能用 getline。

两者混用时缓冲区残留的换行必须先吃掉,见下一张卡。

新函数拆解 · cin.ignore(清掉残留)
cin.ignore(); cin.ignore(n, '\n');
组成含义 / 格式要求
无参吃掉 1 个字符。常用法就是吃掉 cin >> n 后面那个换行。
两参最多吃掉 n 个字符,遇 '\n' 提前停('\n' 一并吃掉)。
为什么需要cin >> 读数字遇空白就停,不把空白取走。残留换行会让下一次 getline 读到空串。
最小可运行示例
示例
int n;
cin >> n;               // 输入 3,缓冲区还剩一个 '\n'
cin.ignore();           // 吃掉它
string s;
getline(cin, s);        // 现在读到的才是真正的第 2 行
注意

漏了这句的症状固定:getline 读到空串,后面所有数据全部错位。

全程只用 cin >> 的程序不需要它。

新函数拆解 · string::size / length(长度)
size_t s.size();
组成含义 / 格式要求
返回值字符个数。结尾的 '\0' 由 string 内部维护,不计入。
类型size_t,无符号整数,和 int 混用会出坑。
等价写法s.length() 完全一样;判断空串用 s.empty()。
最小可运行示例
示例
string s = "abc";
cout << s.size();       // 3

// ★ 经典坑:s 为空时 s.size() - 1 回绕成 1.8e19,当下标用直接 RE
int n = (int)s.size();                    // 先转成有符号
for (int i = n - 1; i >= 0; --i) cout << s[i];   // 倒序输出
注意

s 为空时 s.size() - 1 等于 18446744073709551615。

把 size 存进 int 后,所有下标运算都是带符号的,倒序循环才不会变死循环。

新函数拆解 · 范围 for(新语法)
for (char c : s) { } for (char &c : s) { }
组成含义 / 格式要求
char c副本。循环体里改 c 不影响 s,适合只读统计。
char &c引用,绑定在 s 的元素上。给 c 赋值就是改 s。
const char &c只读又不拷贝。char 拷贝代价极小,结构体才必须这么写。
数组也能用for (int &x : a) cin >> x; 一次读完数组。
最小可运行示例
示例
string s = "Hello, World";
int letters = 0;
for (char c : s) if (isalpha(c)) ++letters;   // 只读:c 是副本

for (char &c : s) c = toupper(c);             // 要改:加 & ,c 是 s 里的字符
cout << s;                                    // HELLO, WORLD

for (char c : s) c = tolower(c);              // ✗ 改副本,s 一个字符都没变
注意

判断标准只有一条:要不要改原串,要改就加 &。

循环体里不要 push_back 或删除元素,长度一变遍历就失效。

新函数拆解 · isdigit(是不是数字字符)
int isdigit(int ch);
组成含义 / 格式要求
参数字符的整数编码,直接把 char 传进去。
返回是 '0'~'9' 返回非 0(真),否则 0。
只认什么只认这 10 个字符,不认 '+', '-', '.', 空格。
最小可运行示例
示例
cout << isdigit('7');    // 非 0
cout << isdigit(' ');    // 0
cout << '7' - '0';       // 7:字符转数值靠减法
注意

返回值是 int,当布尔用,别赋给 char;等价写法是两头都判断的区间比较。

新函数拆解 · isalpha(是不是字母)
int isalpha(int ch);
组成含义 / 格式要求
返回是 'A'~'Z' 或 'a'~'z' 返回非 0,否则 0。
同族isalnum、isspace、islower、isupper、ispunct 用法相同。
为什么用它自己写区间判断容易漏边界,标准库不会错。
最小可运行示例
示例
string s = "Luogu 2024";
int letters = 0, digits = 0, other = 0;
for (char c : s) {
    if (isalpha(c)) ++letters;
    else if (isdigit(c)) ++digits;
    else ++other;
}
// 5 4 1
注意

别写成 c >= 'A' && c <= 'z':'[' '\' ']' '^' '_' '`' 这 6 个会被误判成字母。

新函数拆解 · tolower(转小写)
int tolower(int ch);
组成含义 / 格式要求
行为大写字母返回对应小写;其他字符原样返回。
返回类型int。写 c = (char)tolower(c); 最稳。
不改原值它只返回新字符,tolower(c); 单独一句等于什么都没做。
最小可运行示例
示例
cout << (char)tolower('Q');       // q

string s = "Luogu";
for (char &r : s) r = tolower(r);  // 加 & 才改到原串
cout << s;                        // luogu

char d = 'Q';
tolower(d);                       // ✗ d 仍是 'Q'
注意

最常见的错:for (char c : s) c = tolower(c); 改的是副本,原串一点没变。

新函数拆解 · toupper(转大写)
int toupper(int ch);
组成含义 / 格式要求
行为小写字母返回对应大写;其他字符原样返回。
与 tolower 对称都是“输入一个编码,输出一个编码”,不就地修改。
典型场景把整行统一成一种大小写,之后只按一种形式比较——所有“不区分大小写”题的第一步。
最小可运行示例
示例
string a = "Hello", b = "WORLD";
for (char &c : a) c = toupper(c);   // HELLO
for (char &c : b) c = tolower(c);   // world
cout << a << " " << b;              // HELLO world
注意

统一大小写只做一次就够,不要在每次比较里都调;对数字和空格调用无副作用。

例题怎么想:把“按几下”做成一张 26 元表

题目只问总按键数,与字符顺序无关——每个字符的贡献互不影响,一次累加即可。难点只剩“字符 → 次数”这个映射:键盘是死的,把 a~z 各自的次数按字母顺序写成数组,用 c - 'a' 当下标,一次数组访问就查完,比 8 个 if 又快又不容易写错。

完整代码:26 元查表
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s;
    getline(cin, s);            // 一行含空格,只能整行读

    // press[i]:打出第 i 个字母(a 记 0)要按几下
    // a b c | d e f | g h i | j k l | m n o | p q r s | t u v | w x y z
    int press[26] = {1,2,3, 1,2,3, 1,2,3, 1,2,3, 1,2,3,
                     1,2,3,4, 1,2,3, 1,2,3,4};

    int ans = 0;
    for (char c : s) {              // 只读,不加 & ,c 是副本
        if (c == ' ') ans += 1;     // 空格按 0 键一下
        else ans += press[c - 'a']; // ASCII 连续 ⇒ 下标 = c - 'a'
    }

    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
getline(cin, s);本题用 getline 的唯一理由:一行里含空格。用 cin >> s 只会拿到第一个词。
int press[26]26 个数对应 a~z。手写表出错时错误集中在表里,一眼能查;比一堆 if 快。
{1,2,3, 1,2,3, ...}每 3 个一组,pqrs 和 wxyz 是 4 个一组——这两组有 4 个字母,所以表里出现 4。
for (char c : s)只读遍历,不加引用。c 是副本,改不动 s,而本题也不需要改。
if (c == ' ')空格必须单独处理:它落在 'a' 左侧,直接算 c - 'a' 会得到 -65 这类负下标。
press[c - 'a']三组连续性最直接的用法:'a'-'a'=0,'z'-'a'=25,字母序号就是下标。
cout << ans << "\n";只输出数字,不加任何提示语。
另一种建表:8 个字符串

也可以把键盘存成 string key[10] = {"", "", "abc", "def", ...},每个字符去 8 组里找自己在第几位。它不用手抄 26 个数字,代价是每字符最多 32 次比较——n ≤ 106 时也只有 3×10⁷ 次,照样过。所以选哪个只看你更不容易写错。

这一节最常见的 5 个错(附症状)

WA,答案总是偏小:用 cin >> s 读含空格的整行,只拿到第一个词,s.size() 永远等于词长。

WA,读到空串:cin >> n 后直接 getline,读到残留换行。补 cin.ignore();。

RE 或答案是个天文数字:大写字母、空格代进 press[c - 'a'],下标为负,访问到数组外。

TLE / RE:for (int i = s.size() - 1; i >= 0; --i)。s 为空时 size()-1 回绕成 1.8×10¹⁹,i 转 int 后变负,条件恒真,越界读到崩。先把 size 存进 int。

WA,字符串没变:for (char c : s) c = tolower(c); 改的是副本,加 &。

自己动手复现

关掉本页,把上面的完整代码从头敲一遍,提交 P1765,AC 了再往下。

改一改 ①:输入换成含大写的 Hello World,先猜答案再运行;然后加一行 for (char &c : s) c = tolower(c);,看结果变不变。

改一改 ②:题目改成“输出 2~9 每个数字键各被按了多少次”,用 int cnt[10] 边算边统计。

改一改 ③:先读整数 n,再读 n 行文本求和。故意删掉 cin.ignore() 跑一次,亲眼看它读空。

数学补给 · 为什么大小写只差 32

把三组区间写成二进制,规律立刻出来:

字符十进制二进制区间长度
'0'48011 0000—
'9'57011 100110 个
'A'65100 0001—
'Z'90101 101026 个
'a'97110 0001—
'z'122111 101026 个

'A' 与 'a' 只差第 5 位(权重 32):100 0001 对 110 0001。'Z' 与 'z' 同样只差这一位,所以字母在编码表里的相对位置完全一致,这才有 'a' - 'A' = 32。

数字字符同理:'0' = 48 是区间起点,c - '0' 直接得 0~9;反过来写 d + '0',别写 d + 48——前者与编码表无关,后者只在 ASCII 上成立。

循环移位是同一套想法:(c - 'a' + k) % 26 + 'a' 把整数轴折成长度 26 的环,'z' 后一位自动回到 'a'。k 可能为负时要写 ((c - 'a' + k) % 26 + 26) % 26 + 'a',因为 C++ 的 % 对负数取余仍是负的。

举一反三 · 想清楚这些再往下走
  1. 除了 26 元 int 表,还能用 8 个字符串查表。两种写法的比较次数、抄错风险、扩展到 100 个字符时的代价各是什么?
  2. 为什么 getline 要把 '\n' 取走却不把它放进 string?如果它放进去了,本节两道题分别会出什么错?
  3. 把长度不超过 18 的数字字符串变成 long long,循环怎么写?若长度到 10⁵ 位还能用这个办法吗——这是第 9 节要解决的问题。
  4. c & 32 对 'A'、'0'、'@' 分别返回什么?为什么这三个结果说明用位运算判断大小写之前必须先 isalpha?
  5. 统计单词时把“左右是空格”改成“左右不是字母”,文章里出现逗号、句号、换行时哪种写法更稳?各举一个反例。
  6. 同样是 O(n) 扫描,为什么字符题常常边读边算就够,而不必先存整个串?什么情况下必须先存下来?
自测:能全部回答再进下一节
挑战题 · 统计单词数
洛谷 P1308普及-去洛谷做这题 ↗

给定一个单词和一篇文章,统计它作为独立单词出现了多少次,以及第一次出现的位置。匹配不区分大小写;若它只是一个更长单词的一部分(to 出现在 today 里),不算。

输入格式:第 1 行一个只含字母的字符串,表示单词(长度不超过 10);第 2 行一个只含字母和空格的字符串,表示文章(长度不超过 106)。

输出格式:一行。出现过则输出两个整数——出现次数与第一次出现的位置(文章第一个字符的位置记为 0);从未出现则输出 -1。

引导性提示(先只看这里)

大小写不影响匹配。先把两行统一成同一种大小写,再讨论比较;想想这一步要不要改到原串上。

一次“算作出现”要同时满足两件事:从这个位置起的若干字符完全相同,并且前后都不是单词的字母。

位置从 0 开始数:文章第一个字符是位置 0,不是 1。

想看思路了?点开完整思路

用 getline 读两行:第 2 行含空格,cin >> 只能拿到第一个词。

两串都转小写,之后只比较一种形式。

枚举起点 i(0 ≤ i ≤ n-m),逐字符比较 text[i..i+m-1] 与 word[0..m-1]。

全同后再查边界:左边是 i == 0 或 text[i-1] == ' ',右边是 i+m == n 或 text[i+m] == ' '。

两边都满足才计数,第一次满足时记下 i。复杂度 O(n·m) ≤ 10⁷。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string word, text;
    getline(cin, word);          // 第 1 行:要查的单词
    getline(cin, text);          // 第 2 行:文章,含空格

    // 统一转小写。必须用引用,否则改的是副本,原串一点没动
    for (char &c : word) c = tolower(c);
    for (char &c : text) c = tolower(c);

    int n = (int)text.size(), m = (int)word.size();
    int cnt = 0, first = -1;     // first = -1 表示没出现,0 是合法位置

    for (int i = 0; i + m <= n; ++i) {       // i 是起点,保证 i+m-1 < n
        bool same = true;
        for (int j = 0; j < m; ++j)          // 手写比较,省一次子串拷贝
            if (text[i + j] != word[j]) { same = false; break; }
        if (!same) continue;

        // 完整单词:左边是开头或空格,右边是结尾或空格
        // || 的先后不能换:i == 0 时不能去访问 text[-1]
        bool leftOK  = (i == 0)     || (text[i - 1] == ' ');
        bool rightOK = (i + m == n) || (text[i + m] == ' ');

        if (leftOK && rightOK) {
            ++cnt;
            if (first == -1) first = i;      // 只记第一次
        }
    }

    if (cnt == 0) cout << -1 << "\n";
    else          cout << cnt << " " << first << "\n";
    return 0;
}
关键片段拆解
片段作用
getline(cin, word);第一行没有空格也用 getline,省掉“cin >> 之后要不要 ignore”的判断。
for (char &c : word) c = tolower(c);引用绑定到原串元素才改得动;漏掉 & 就全场 0 次。
i + m <= n起点上限。写成 i < n 会越界访问 text[i+m-1]。
text[i + j] != word[j]逐字符比较,不同就立刻 break。
(i == 0) || (text[i - 1] == ' ')短路求值:i 为 0 时右边不执行,不会访问 text[-1]。调换顺序就在第一个单词上越界。
(i + m == n) || (text[i + m] == ' ')右边界。漏掉它,to 会把 today 开头的 to 也算一次。
first == -1用 -1 表示“没出现”,因为位置 0 是合法的。
这题最容易错的地方
  • 用 cin >> text 读文章,只拿到第一个词,答案几乎是 0。
  • 转小写忘写 &,word 仍是大写,任何输入都匹配不上,输出 -1。
  • 先写 text[i-1] 再判 i == 0,下标 -1 越界读,拿到随机值。
  • 只判左边界不判右边界:文章 “today to” 会把 today 开头的 to 也算一次。
  • 位置从 1 数起,答案整体差 1。
延伸阅读 / 参考