1. 这道题到底在考什么:从"统计单词"到"输出尾字符"的完整拆解
PTA 上的 7-1 这类题目,名字看着朴素,实际上是一道把字符串处理、容器使用和边界判断揉在一起的基础题。题目要求很直白:给一段文本,统计其中单词的数量,并且把每个单词的最后一个字符输出出来。关键词里那句"就是非得用 vector c++"其实透露了很多信息——出题人或者做题人明显在纠结容器选型,而这道题恰恰是一个非常适合拿来对比"数组 vs vector vs string 流"的典型场景。
先把需求翻译成人话。输入通常是一行或若干行文本,单词之间用空格分隔,可能夹杂标点,也可能有连续空格。你需要做两件事:第一,数出一共有多少个单词;第二,按顺序把每个单词的最后一个字符打印出来。听起来简单,但真正动手写的时候,坑一个接一个:怎么定义"单词"?连续空格怎么处理?行尾有没有换行?标点算不算单词的一部分?这些细节不搞清楚,代码写出来要么答案错误,要么在某些测试点上莫名其妙挂掉。
这道题适合谁来参考?如果你正在学 C++ 的 STL,刚接触 vector、string、stringstream,这道题是一个非常好的练手素材。它不需要复杂算法,但能逼你把输入处理、容器遍历、字符访问这些基本功练扎实。如果你已经写过一些题,但总是卡在"格式错误"或者"段错误"上,那这篇内容也能帮你把那些模糊的地带理清楚。
我先把核心结论放在前面:这道题用 vector 完全没问题,但 vector 不是唯一解,也不是最优解。真正决定你能不能 AC 的,不是容器选了什么,而是你对输入流的理解和对边界情况的处理。下面我会从需求分析、容器选型、代码实现、踩坑排查几个角度,把这道题彻底讲透。
2. 为什么"非得用 vector"这个执念值得聊一聊
2.1 vector 在这道题里到底承担了什么角色
很多人一看到"统计单词"就条件反射地想到 vector,原因很简单:单词数量不确定,用数组得开一个足够大的空间,而 vector 可以动态扩容,看起来更"高级"也更安全。这个思路本身没错,但我们需要搞清楚 vector 在这道题里究竟存了什么。
一种常见的写法是:用 stringstream 把输入拆成一个个单词,每读到一个单词就 push_back 进 vector ,最后 vector 的 size 就是单词数量,再遍历 vector 输出每个字符串的 back()。这个方案逻辑清晰,可读性好,适合初学者理解"拆分—存储—遍历"这个通用套路。
但问题在于,这道题真的需要把所有单词都存下来吗?统计数量和输出尾字符这两个操作,其实都可以在读取的过程中顺便完成。你读到一个单词,计数加一,输出它的最后一个字符,然后就可以把这个单词丢掉了。也就是说,vector 在这里并不是必需的,它带来的额外空间开销在这个场景下没有换来任何实际收益。
2.2 不用 vector 的几种替代方案对比
我把几种常见方案列出来,方便你根据自己掌握的知识点选择:
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| vector + stringstream | 拆分后全部存入容器再处理 | 逻辑直观,便于二次操作 | 空间浪费,代码略长 |
| stringstream 边读边处理 | 读一个处理一个,不存储 | 空间 O(1),代码简洁 | 需要理解流的状态 |
| cin >> 直接读 | 利用 cin 自动跳过空白 | 最短,最省心 | 对整行格式控制较弱 |
| getline + 手动扫描 | 逐字符判断分隔 | 控制粒度最细 | 边界处理繁琐,易错 |
从表格能看出来,如果题目只是"统计数量 + 输出尾字符",cin >> 配合循环其实是最省事的。C++ 的输入流在读取 string 时会自动跳过前导空白,并在遇到空白时停止,这正好符合"以空白分隔单词"的定义。你甚至不需要 stringstream,直接 while(cin >> word) 就能把每个单词依次拿到手。
那为什么还有人坚持用 vector?我观察下来,主要有两个原因:一是老师或教材强调"要用容器",二是做题人习惯了"先存后处理"的思维定式。这两种情况都能理解,但如果你想真正提升,建议至少把"边读边处理"的写法也练一遍,因为它能帮你建立流式处理的意识,这在后面处理大数据量输入时会非常有用。
2.3 选型背后的判断逻辑
我个人的判断标准是这样的:如果后续操作需要反复访问已经读过的单词,或者需要对单词集合做排序、去重、查找,那用 vector 存下来是合理的。但如果只是顺序扫描一遍就结束,那就没必要存。这道题显然属于后者。
还有一个细节值得注意:题目要求输出的是"单词的最后一个字符",这个操作对每个单词是独立的,不依赖其他单词的信息。这种"局部可处理"的特征,进一步说明存储整个集合是多余的。你在读到一个单词的瞬间,就已经掌握了输出它尾字符所需的全部信息。
提示:不要为了用容器而用容器。容器是工具,不是目的。判断要不要存,关键看后续有没有"回看"的需求。
3. 输入处理的那些坑:空格、换行与流状态
3.1 cin >> 到底跳过了什么
很多人对 cin >> 的理解停留在"读一个单词",但具体它跳过了哪些字符、在什么位置停下,说不清楚。这里我把规则讲明白:当 cin 以 string 为目标进行读取时,它会先跳过所有前导空白字符(空格、制表符、换行符都算),然后从第一个非空白字符开始读,一直读到遇到下一个空白字符为止,这个空白字符会被留在缓冲区里,不会被消耗掉。
这个行为对本题来说简直是量身定做。因为题目说单词之间用空格分隔,而 cin >> 天然就是按空白切分的。你写 while(cin >> s),它会自动帮你处理连续空格、行首空格、行尾换行这些情况,你完全不用手动写循环去跳过空格。
但这里有一个容易忽略的点:cin >> 在读不到任何字符时会失败,循环条件就为假,循环结束。这正是我们判断"输入结束"的依据。在 PTA 这类在线评测系统里,输入通常是从文件重定向过来的,读到文件末尾时 cin 会进入失败状态,while(cin >> s) 自然退出。所以你不需要预先知道有多少个单词,也不需要读一个特殊的结束标记。
3.2 连续空格和行尾空格的陷阱
如果你不用 cin >>,而是用 getline 读整行再手动分割,那连续空格就是第一个要面对的坑。比如输入是 "hello world"(中间三个空格),你按单个空格去 split,会得到空字符串,导致单词数量统计错误。解决办法通常是跳过连续的分隔符,或者用 stringstream 来帮你做这件事。
stringstream 的好处在于,它内部对 >> 运算符的处理和 cin 是一致的,同样会跳过连续空白。所以如果你已经用 getline 把整行读进来了,可以再套一层 stringstream,用 ss >> word 来逐个提取单词,这样就不用自己写跳过空格的逻辑了。
行尾空格是另一个隐蔽的坑。有些测试数据的最后一行可能带一个空格,如果你用 getline 读进来后手动按空格分割,末尾可能多出一个空字符串。而用 cin >> 或 stringstream 就不会有这个问题,因为它们天然忽略尾部空白。
3.3 多行输入时该怎么读
题目没有明确说输入是一行还是多行,这时候最稳妥的做法就是不要假设行数。用 while(cin >> s) 这种写法,无论输入是一行、两行还是十行,都能正确地把所有单词依次读出来。这也是我推荐这种写法的原因之一:它对输入的行结构不敏感,鲁棒性最好。
如果你非要用 getline,那就得写一个外层循环 while(getline(cin, line)),然后对每一行再套 stringstream。这样代码会变长,而且如果某一行是空行,你还得决定要不要跳过。相比之下,cin >> 的方案把这些麻烦全部省掉了。
注意:在 PTA 上做题时,不要假设输入只有一行。很多题目的测试点会故意用多行输入来考察你的处理方式,用 while(cin >> s) 是最安全的。
4. 从零写出可 AC 的代码:三种实现路径
4.1 最简路径:cin 直接流式处理
先看最短的写法,这也是我最推荐的:
#include <iostream> #include <string> using namespace std; int main() { string word; int count = 0; while (cin >> word) { count++; cout << word.back(); } cout << endl; cout << count << endl; return 0; }这段代码的逻辑非常直接:每读到一个单词,计数加一,输出它的最后一个字符。word.back() 是 C++11 引入的成员函数,返回字符串最后一个字符的引用,等价于 word[word.size() - 1],但写起来更简洁,也不容易在空字符串时出错(虽然这里不会有空字符串)。
关于输出格式,题目通常要求先输出所有尾字符,再输出单词数量,或者反过来。具体顺序一定要看清楚题目描述。我这里假设的是先输出尾字符序列,再输出数量。如果题目要求数量在前,把两行输出调换即可。
这个写法的时间复杂度是 O(总字符数),空间复杂度是 O(最长单词长度),非常高效。它不需要 vector,不需要 stringstream,是这道题的最优解之一。
4.2 vector 路径:满足"非得用 vector"的需求
如果你确实需要用 vector,可以这样写:
#include <iostream> #include <vector> #include <string> #include <sstream> using namespace std; int main() { string line, word; vector<string> words; while (getline(cin, line)) { stringstream ss(line); while (ss >> word) { words.push_back(word); } } for (int i = 0; i < (int)words.size(); i++) { cout << words[i].back(); } cout << endl; cout << words.size() << endl; return 0; }这里用 getline 逐行读取,再用 stringstream 把每行拆成单词存入 vector。最后遍历 vector 输出尾字符和数量。注意 words.size() 返回的是 size_t 类型,直接和 int 比较可能会有符号警告,所以我加了 (int) 强制转换,或者你也可以用 size_t i 来循环。
这个写法比第一种长了将近一倍,但它的好处是单词都存下来了,如果后续还要做别的操作(比如按长度排序、查找特定单词),可以直接在 vector 上做。不过对于本题来说,这些额外能力用不上。
4.3 手动扫描路径:理解底层字符处理
还有一种更"原始"的写法,用 getline 读整行后逐字符扫描,手动判断单词边界:
#include <iostream> #include <string> using namespace std; int main() { string line; int count = 0; string lastChars; while (getline(cin, line)) { int n = line.size(); int i = 0; while (i < n) { while (i < n && line[i] == ' ') i++; if (i >= n) break; int start = i; while (i < n && line[i] != ' ') i++; count++; lastChars += line[i - 1]; } } cout << lastChars << endl; cout << count << endl; return 0; }这段代码的核心是两个内层 while:第一个跳过所有空格,第二个找到单词的结束位置。单词的最后一个字符就是 line[i-1]。这种写法能让你彻底理解"单词边界"是怎么定义的,但代码量最大,也最容易在边界上出错,比如行尾没有空格时 i-1 是否越界、空行怎么处理等。
我建议你把这段代码当作学习材料读一遍,理解它的扫描逻辑,但实际做题时优先用前两种写法。
5. 实测中反复出现的错误与排查链路
5.1 输出格式错误:最冤的失分点
PTA 的题目对输出格式极其敏感。我见过太多人算法完全正确,但因为多输出一个空格、少输出一个换行,导致整道题 0 分。这道题常见的格式问题有两个:一是尾字符之间加了空格,二是最后没有换行。
题目如果说"输出单词的最后一个字符",通常意味着把这些字符连在一起输出,中间不加任何分隔。如果你写成 cout << word.back() << " ",那就会多出空格,答案错误。至于换行,虽然大多数评测系统对行尾换行不敏感,但养成输出 endl 的习惯总没错。
排查方法很简单:拿题目给的样例输入,把样例输出和你的程序输出逐字符对比。特别注意行尾是否有不可见字符。如果样例输出是 "od" 加换行,你的输出是 "od " 加换行,那就是空格的问题。
5.2 单词数量统计偏差:空字符串在作怪
如果你用 getline + 手动 split 的写法,很容易把连续空格产生的空字符串也计入单词数量。比如 "a b"(两个空格),按单个空格分割会得到 ["a", "", "b"],长度是 3,但实际只有 2 个单词。
这个问题的根因是你把"分隔符"和"单词"混为一谈了。正确的做法是:分割后跳过所有空字符串,或者干脆用 stringstream / cin >> 这种自动跳过连续空白的工具。如果你坚持手动分割,那就要在 push_back 之前加一个 if (!word.empty()) 的判断。
排查时可以用一个带连续空格的测试用例,比如 "hello world foo",正确答案是 3 个单词,尾字符是 'o'、'd'、'o'。如果你的程序输出 5 个单词,那就是空字符串没过滤掉。
5.3 段错误与越界:back() 和下标访问的边界
word.back() 在字符串非空时是安全的,但如果你的逻辑出了问题,读进来一个空字符串,那 back() 就是未定义行为,可能直接崩溃。虽然用 cin >> 或 stringstream 不会产生空字符串,但手动扫描时如果边界判断写错,就可能出现空单词。
另一个常见越界是 line[i-1] 这种写法。如果 i 等于 0,i-1 就是负数,访问 line[-1] 会越界。这种情况通常发生在你的跳过空格逻辑有漏洞,导致进入单词扫描时 i 没有正确前进。排查时可以在关键位置打印 i 的值,看看它是否符合预期。
提示:在本地调试时,用 g++ 的 -fsanitize=address 选项编译,可以帮你快速定位越界访问。命令是 g++ -fsanitize=address -g main.cpp -o main。
5.4 多行输入只处理了第一行
这个坑的典型症状是:样例只有一行时能过,但隐藏测试点有多行时就挂了。根因是你用了 if(getline(cin, line)) 而不是 while(getline(cin, line)),只读了一行就结束了。
如果你用的是 cin >> word 的写法,这个问题天然不存在,因为 while(cin >> word) 会一直读到输入结束。这也是我反复推荐这种写法的原因:它在输入行数这个维度上是最鲁棒的。
排查方法:自己构造一个多行输入,比如三行,每行几个单词,看看你的程序是不是把所有行的单词都统计进去了。如果只统计了第一行,那就是循环条件写错了。
6. 关于这道题的一些延伸思考与练习建议
这道题虽然只有 15 分,但它涉及的知识点其实很密集:输入流的空白处理、字符串的尾部访问、容器的选择、循环的边界控制、输出格式的精确匹配。把这几个点都吃透,你在后面做更复杂的字符串题时会轻松很多。
我建议你在 AC 之后,做几个变体练习来巩固。第一个变体是:输出每个单词的首字符而不是尾字符,看看代码要改哪里。第二个变体是:统计所有单词的总字符数(不含空格),这会逼你思考"单词长度"怎么累加。第三个变体是:找出最长的单词并输出它,这就需要你真正把单词存下来做比较,vector 这时候就派上用场了。
还有一个值得思考的问题是:如果单词之间不只有空格,还有逗号、句号、分号,你该怎么处理?这时候 cin >> 就不够用了,因为它只认空白字符。你需要用 getline 读整行,然后手动判断哪些字符算分隔符。这个扩展会让你对"分词"这件事有更深的理解。
最后说一个我自己的习惯:每做完一道 PTA 题,我都会把代码里用到的每个函数、每个语法点记下来,比如 back()、stringstream、getline 的返回值类型。积累多了之后,你会发现很多题目的解法都是相通的,无非是这些基础模块的不同组合。这道 7-1 就是一个很好的起点,它简单,但不简陋,值得你花时间把每个细节都抠清楚。