题单 · 习题解析

洛谷 P1598 [USACO03FEB] 垂直柱状图

★★★ 全书里「**对拍别 strip、要逐字节比**」最锋利的一次:同一批 1200 轮数据,逐字节比时「行末留空格」那个错法被抓 **284 / 300**,而把每行末尾的空格去掉再比一次 —— **整列变成 0**,另外两个错法一个不少 ⇒ 这不是「可能漏一点」,是一类 bug 整个消失,⚠ 而这道题恰恰最容易让人想去 strip(输出里全是空格,肉眼看两份「明明一模一样」);★★★ 题面和数据打架:题面写「四行**大写字母**」,官方样例里却有 `.`(`c - 'A'` = **−19**)、`!`(**−32**)和空格(**−33**)⇒ 不跳过就是**负下标写数组**,⚠⚠ 而后果**不是 WA 是 UB** —— `cnt[0..25]` 一个字节没被改,答案完全正确,对拍、样例、`-Wall` 三样都看不见;★★★ 「26 个字母各出现同样多次」那一档把三个错法**一起打成能证的 0**(Z 每层都有星 / 26 个字母全出现 / 所有层长得一样)⇒ **拿最整齐的数据测,三个 bug 一个都测不出来**;★★ 官方那唯一一组样例是个**全字母句(pangram)**⇒ 它把「最后一行只打出现过的字母」那个错法整个放过 —— 出题人挑全字母句本意多半是让柱状图好看,**而它顺手藏起了一个真实的错法**;⚠ 顺带一条提醒:档 0 里两个错法都是 284,**可交集只有 268** —— 两列数字相同不等于触发线相同([第 46 章 P2114](/sol/p2114/) 那次是真共用)

原题:洛谷 P1598出自 第 47 章 字符串基础:读进来、切开、比对 的题单题面本地存档:2026-09-07
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1598,日期见页头。两边不一致时信原站。

题目描述

写一个程序从输入文件中去读取四行大写字母(全都是大写的,每行不超过 100 个字符), 然后用柱状图输出每个字符在输入文件中出现的次数。严格地按照输出样例来安排你的输出格式。

输入格式

四行字符,由大写字母组成,每行不超过 100 个字符。

输出格式

由若干行组成,前几行由空格和星号组成,最后一行则是由空格和字母组成的。 在任何一行末尾不要打印不需要的多余空格。不要打印任何空行。

说明 / 提示

每行输出后面不允许出现多余的空格。

数据范围

USACO 2003 FEB。时限 1 秒,内存 128000 KB(125 MB)。

输入输出样例

输入

THE QUICK BROWN FOX JUMPED OVER THE LAZY DOG.
THIS IS AN EXAMPLE TO TEST FOR YOUR
HISTOGRAM PROGRAM.
HELLO!

输出

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

⚠⚠ 注意这组样例的输入里有 .! —— 而题面说「由大写字母组成」。 第 ② 步整节都在说这件事。

1★ 算法那一栏是空的:数 26 个数,再竖着打出来

★ 唯一要想清楚的是「一行长什么样」

统计好 cnt[0..25] 之后,设最大值是 mx,那么从上往下第 h 层(h = mx, mx-1, …, 1)是:

   第 i 列(i = 0..25):cnt[i] >= h 就打 '*',否则打 ' '
   列与列之间:一个空格
   ⚠ 然后把这一行末尾的空格全部去掉        <- 题面说了两遍的那句话

最后再打一行 A B C … Z26 个字母一个不少,包括一次都没出现过的)。

⇒ 这道题的全部难度就在最后那两句注释上。它们对拍抓不抓得到,是这一页的主线。

p1598.cpp★ 正解:统计 → 逐层打 → 行末去空格
// P1598 垂直柱状图 —— 正解:数 26 个字母,再竖着打出来
//
// ★★ 这道题的算法只有一句「数一遍」,**真正的考点是输出格式**,题面把话说得很死:
// 「在任何一行末尾不要打印不需要的多余空格」「不要打印任何空行」。
// ⇒ 所以这一页量的东西也和别的页不一样:**它是全书里「对拍绝对不能 strip」最锋利的一次**
// —— 三个错法里有一个只在空格上不同,一 strip 就全绿(见 p1598Chars.cpp)。
//
// ⚠⚠ 另外有一处题面和数据打架,必须在这儿说清楚:
// 题面写「四行**大写字母**」,而**官方样例里有 `.`、`!` 和空格** ⇒ **非字母必须跳过**。
// ⚠ 而「不跳过」的后果不是答案错,是 `cnt[c - 'A']` 里的下标变成**负数**
// (`'.' - 'A'` = −19、`'!' - 'A'` = −32、`' ' - 'A'` = −33)—— 那是 UB,
// 它在这道题上**不改变答案**,所以对拍和样例都看不见它。
#include <bits/stdc++.h>
using namespace std;
int main() {
int cnt[26] = {0};
string line;
for (int i = 0; i < 4; i++) {
if (!getline(cin, line)) break;
for (char c : line)
if (c >= 'A' && c <= 'Z') cnt[c - 'A']++; // ★ 只数大写字母,别的一律跳过
}
int mx = 0;
for (int i = 0; i < 26; i++) mx = max(mx, cnt[i]);
for (int h = mx; h >= 1; h--) { // ★ 从最高那一层往下打
string row;
for (int i = 0; i < 26; i++) {
if (i) row.push_back(' ');
row.push_back(cnt[i] >= h ? '*' : ' ');
}
while (!row.empty() && row.back() == ' ') row.pop_back(); // ⚠ 行末不许有多余空格
printf("%s\n", row.c_str());
}
for (int i = 0; i < 26; i++) printf("%c%c", 'A' + i, i == 25 ? '\n' : ' ');
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1598Word.cpp★ 另一种正确写法:while (cin >> s) 读到 EOF
★ 顺带一条:先问一句「我到底要不要行」

这道题只关心「每个大写字母出现了几次」⇒ 空格和换行怎么切都无所谓while (cin >> s) 一路读到 EOF 就够了(1200 轮和正解逐字节相同)。 ⇒ 第 47 章第 2 步那个「getline 撞见残留换行」的坑, 在不需要行结构的题上根本不存在

2⚠⚠ 题面和数据打架 —— 而照着题面写的后果不是 WA,是负下标

p1598Chars.cpp⚠ 官方样例里到底有哪些字符
// P1598 解析页上那几个「和算法无关」的数字的出处。
// ./p1598Chars < 输入 人话版
// ./p1598Chars csv < 输入 给 check:viz 用
//
// 它回答三件事:
// ① ⚠⚠ **题面和数据打架**:题面写「四行**大写字母**」,可这份输入里有哪些非字母字符?
// 它们的 `c - 'A'` 是多少(也就是「不跳过」的话会往数组外面写到哪儿去);
// ② ★★ 官方那组样例是不是**全字母句**(26 个字母一个不缺)——
// 这一条直接决定了「最后一行只打出现过的字母」那个错法会不会被样例打死;
// ③ ★ `Z` 的出现次数和最大值 —— 「行末留空格」那个错法的触发条件就是 `cnt['Z'] < max`。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
bool csv = argc > 1 && string(argv[1]) == "csv";
int cnt[26] = {0};
map<char, int> other;
string line;
while (getline(cin, line))
for (char c : line) {
if (c >= 'A' && c <= 'Z') cnt[c - 'A']++;
else other[c]++;
}
int mx = 0, missing = 0;
for (int i = 0; i < 26; i++) { mx = max(mx, cnt[i]); if (!cnt[i]) missing++; }
if (csv) {
printf("missing,%d\n", missing);
printf("maxCnt,%d\ncntZ,%d\n", mx, cnt[25]);
string names;
for (auto& kv : other) {
if (!names.empty()) names.push_back('|');
names += (kv.first == ' ' ? string("SPACE") : string(1, kv.first));
names += ":" + to_string((int)kv.first - 'A');
}
printf("others,%s\n", names.c_str());
return 0;
}
printf("① 这份输入里的非字母字符(题面说「由大写字母组成」):\n");
for (auto& kv : other)
printf(" %-6s 出现 %2d 次,c - 'A' = %d\n",
kv.first == ' ' ? "空格" : string(1, kv.first).c_str(), kv.second, (int)kv.first - 'A');
printf(" ⇒ 不跳过它们的话,cnt[c - 'A'] 会写到数组**外面**去(负下标,UB)\n");
printf("② 26 个字母里一次都没出现的有 %d 个 ⇒ %s\n",
missing, missing == 0 ? "★ 这是一个全字母句" : "不是全字母句");
printf("③ 最大出现次数 = %d,Z 出现 %d 次 ⇒ 「行末留空格」那个错法%s现形\n",
mx, cnt[25], cnt[25] < mx ? "会" : "不会");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 三个非字母字符,三个负下标

题面写着「四行大写字母」「由大写字母组成」,可官方样例的输入里有:

字符 出现 c - 'A'
空格 16 次 −33
! 1 次 −32
. 2 次 −19

非字母必须跳过。⚠⚠ 而「不跳过」的后果不是答案错 —— 那三个负下标写到的是 cnt 数组外面cnt[0..25] 一个字节都没被改 ⇒ 答案完全正确,对拍 300 轮一次都抓不到,官方样例也照过。

★★★ 可它是未定义行为:往数组外面写,编译器和运行时都不欠你任何保证。 ⇒ 这和第 46 章 P1582 那条是同一类,而这次更隐蔽: 它错得没有任何依据,而对拍、样例、-Wall 三样都看不出来。 ⇒ ★★ 所以这一条只能靠读题面对着数据核一遍发现 —— 这也正是每个解析页都转录题面的理由。

3⚠ 三个错法,全长在输出格式上

✗ 错法一:星号行末尾留了多余空格(少了那一句 rtrim)

题面把这句话写了两遍(输出格式一次、提示里又一次),可它在屏幕上是看不见的。 ★ 触发条件是一句精确的话:Z 的出现次数小于最大值 —— 只要某一层里 Z 那一列是空的,这一行就带着一个多余空格结束。 ⚠ 反过来说:如果 Z 恰好是出现最多的字母,这个 bug 一次都不会现形。

p1598Trail.cpp✗ 错法一:行末留空格
// P1598 ✗ 错法一:星号那几行末尾留了多余空格
//
// ⚠ 题面两处都写了这件事(输出格式一次、提示里又一次),可它是**看不见**的错 ——
// 页面上、终端里,一行末尾多几个空格和不多,长得一模一样。
// ★ 触发条件是一句精确的话:**`Z` 的出现次数小于最大值**
// (只要某一层里 `Z` 那一列是空的,这一行就带着多余空格结束)。
// ⇒ ⚠ 反过来说:**如果 `Z` 恰好是出现最多的字母,这个 bug 就一次都不会现形。**
// ⇒ ★★★ 而它最要命的地方在对拍上:**逐字节比抓得到,strip 之后一个都抓不到。**
#include <bits/stdc++.h>
using namespace std;
int main() {
int cnt[26] = {0};
string line;
for (int i = 0; i < 4; i++) {
if (!getline(cin, line)) break;
for (char c : line) if (c >= 'A' && c <= 'Z') cnt[c - 'A']++;
}
int mx = 0;
for (int i = 0; i < 26; i++) mx = max(mx, cnt[i]);
for (int h = mx; h >= 1; h--) {
string row;
for (int i = 0; i < 26; i++) { if (i) row.push_back(' '); row.push_back(cnt[i] >= h ? '*' : ' '); }
printf("%s\n", row.c_str()); // ⚠ 少了那一句 rtrim
}
for (int i = 0; i < 26; i++) printf("%c%c", 'A' + i, i == 25 ? '\n' : ' ');
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
✗ 错法二:最后那行只打「出现过的」字母

样例里最后一行是完整的 26 个字母 —— 包括一次都没出现过的那些。 ★ 触发条件:存在某个字母一次都没出现。 ⚠⚠ 而官方那组样例恰好是一个全字母句(26 个字母一个不缺,上一步已经数过) ⇒ 它放过这个错法。

p1598Miss.cpp✗ 错法二:漏掉没出现过的字母
✗ 错法三:柱子上下颠倒(从矮往高打)

for (h = 1; h <= mx; h++)for (h = mx; h >= 1; h--) 只差一个方向。 ★ 触发条件:不是每个字母的次数都落在 {0, 最大值} —— 反过来,如果每个出现过的字母都出现同样多次,那么每一层长得一模一样,正着打反着打完全相同

p1598Flip.cpp✗ 错法三:上下颠倒

4★★★ 这一页的主线:对拍一 strip,就有一整个错法凭空消失

p1598Gen.cpp★ 生成器:档 2 让 26 个字母各出现同样多次
// P1598 的生成器:./p1598Gen 种子 [档位]
//
// ★ 三个错法各靠什么现形:
// · p1598Trail(行末留空格)→ **Z 的次数 < 最大次数**(某一层里 Z 那列是空的);
// · p1598Miss(最后一行只打出现过的)→ **存在一次都没出现的字母**;
// · p1598Flip(柱子上下颠倒)→ **不是每个字母的次数都 ∈ {0, 最大值}**。
//
// 档位:
// 0 ★ 顺手写的:四行随机大写字母,每行 10~40 个
// 1 ⚠ 保证 26 个字母全出现(★ 官方样例正是这一档 —— 它是个全字母句)
// 2 ⚠⚠ **26 个字母各出现同样多次** ⇒ 上面三条触发条件**同时不成立**,三个错法一起是 0
// 3 ★ 最终档:随机字母 + 混进 `.` `!` 和空格(题面说没有,可官方样例里有)
//
// ⚠ rng() 一律先落到具名变量再传参([第 24 章 P1776](/sol/p1776/) 那一跤)。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1;
int mode = argc > 2 ? atoi(argv[2]) : 0;
mt19937 rng(seed * 1000003u + 20260907u);
vector<char> pool;
if (mode == 2) {
unsigned r = rng() % 4u;
int k = 1 + (int)r; // 每个字母各来 k 次
for (int i = 0; i < 26; i++) for (int j = 0; j < k; j++) pool.push_back((char)('A' + i));
} else {
if (mode == 1) for (int i = 0; i < 26; i++) pool.push_back((char)('A' + i));
unsigned r = rng() % 60u;
int extra = 20 + (int)r;
for (int i = 0; i < extra; i++) { unsigned c = rng() % 26u; pool.push_back((char)('A' + c)); }
}
shuffle(pool.begin(), pool.end(), rng);
/* 摊成四行;每行的字母之间随机插空格,档 3 还会混进 . 和 ! */
size_t p = 0;
for (int i = 0; i < 4; i++) {
size_t take = (i == 3) ? pool.size() - p : (pool.size() - p) / (size_t)(4 - i);
string line;
for (size_t k = 0; k < take && p < pool.size(); k++, p++) {
line.push_back(pool[p]);
unsigned g = rng() % 4u;
if (g == 0) line.push_back(' ');
if (mode == 3) {
unsigned d = rng() % 12u;
if (d == 0) line.push_back('.');
if (d == 1) line.push_back('!');
}
}
printf("%s\n", line.c_str());
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

四档 × 300 轮(正解 vs while (cin >> s) 版:1200 轮 0 组不一致):

档位 ✗ 行末空格 ✗ 漏字母 ✗ 上下颠倒
0 ★ 顺手写的(四行随机大写字母) 284 284 300
1 ⚠ 保证 26 字母全出现(官方样例正是这一档) 284 0 300
2 ⚠⚠ 26 个字母各出现同样多次 0 0 0
3 ★ 最终档(混进 . ! 空格) 284 284 300

同一批数据,把每行末尾的空格去掉再比一次:

档位 ✗ 行末空格 ✗ 漏字母 ✗ 上下颠倒
0 / 1 / 2 / 3 ★★★ 0 / 0 / 0 / 0 284 / 0 / 0 / 284 300 / 300 / 0 / 300
★★★ 四条读得出来的结论
  1. ★★★ 一 strip,「行末空格」那一列就整列变成 0,而另外两个一个不少。 ⇒ 本书从第 26 章 P1040 起反复说的那句「对拍别 strip、要逐字节比」, 在这一页拿到了最锋利的证据:它不是「可能漏一点」,是这一类 bug 整个消失。 ⚠ 而这道题恰恰是最容易让人想去 strip 的那种题 —— 输出里全是空格, 肉眼看两份输出「明明一模一样」。

  2. ★★★ 档 2 把三个错法一起打成 0,而三个 0 都能不跑程序地证明

    错法 为什么在「每个字母同样多次」上必然对
    行末空格 每一层里 Z 都有星 ⇒ 每行本来就以 * 结尾,没有可去的空格
    漏字母 26 个字母全出现过 ⇒ 一个都不会被跳掉
    上下颠倒 所有层长得一样 ⇒ 正着打和反着打逐字节相同

    第 33 章 P1266本章 P1200 那条的第三次: 一个对照档可以同时给三个 0 交代清楚 —— 反过来说, 拿「最整齐」的数据测,这三个 bug 一个都测不出来。

  3. ★★ 档 0 里「行末空格」和「漏字母」都是 284,可它们的交集只有 268 —— ⇒ ⚠ 两列数字相同,不等于触发线相同第 46 章 P2114 那次是真的共用一条线:交集 32、两列都是 32)。 ⇒ 想说「它俩是同一件事」,得把交集数出来。

  4. 十二格「触发 ≡ 抓获」一个不差(逐字节那张表),三条触发条件各是一句精确的话。

5★ 官方那唯一一组样例:打死两个,放过一个

★★ 而「放过的那个」原因很具体:它是一个全字母句
✗ 行末空格 ✗ 漏字母 ✗ 上下颠倒
官方样例 放过

★ 那四行文字连起来是一个全字母句(26 个字母一个不缺,第 ② 步数过) ⇒ 「只打出现过的字母」在它上面一个字都不会错

⇒ ★★ 这是「这组样例在结构上问不出这个问题」的又一次, 而这次那个「结构」有个名字:pangram。 ⚠ 出题人挑一句全字母句当样例,本意多半是「让柱状图好看一点」—— 而它顺手把一个真实的错法藏了起来。

6★ 哪一版就已经能过了

★ 这道题只有一版,而它的检查清单有四条
版本 结果 说明
p1598.cpp AC 统计 → 逐层 → rtrim → 字母行
p1598Word.cpp AC 1200 轮逐字节相同
✗ 行末空格 WA strip 之后完全看不见
✗ 漏字母 WA ⚠ 官方样例放过
✗ 上下颠倒 WA 样例就死

⇒ ★★ 一句话带走:这道题的算法一句话就写完了,而它值得留下的是一条对拍纪律 —— 逐字节比,别 strip。 这一页把代价量出来了: 一 strip,一整类 bug(284 / 300)当场变成 0。