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 … Z(26 个字母一个不少,包括一次都没出现过的)。
⇒ 这道题的全部难度就在最后那两句注释上。它们对拍抓不抓得到,是这一页的主线。
// 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;}点「运行 ▶」看结果
这道题只关心「每个大写字母出现了几次」⇒ 空格和换行怎么切都无所谓,
while (cin >> s) 一路读到 EOF 就够了(1200 轮和正解逐字节相同)。
⇒ 第 47 章第 2 步那个「getline 撞见残留换行」的坑,
在不需要行结构的题上根本不存在。
2⚠⚠ 题面和数据打架 —— 而照着题面写的后果不是 WA,是负下标
// 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;}点「运行 ▶」看结果
题面写着「四行大写字母」「由大写字母组成」,可官方样例的输入里有:
| 字符 | 出现 | c - 'A' |
|---|---|---|
| 空格 | 16 次 | −33 |
! |
1 次 | −32 |
. |
2 次 | −19 |
⇒ 非字母必须跳过。⚠⚠ 而「不跳过」的后果不是答案错 ——
那三个负下标写到的是 cnt 数组外面,cnt[0..25] 一个字节都没被改
⇒ 答案完全正确,对拍 300 轮一次都抓不到,官方样例也照过。
★★★ 可它是未定义行为:往数组外面写,编译器和运行时都不欠你任何保证。
⇒ 这和第 46 章 P1582 那条是同一类,而这次更隐蔽:
它错得没有任何依据,而对拍、样例、-Wall 三样都看不出来。
⇒ ★★ 所以这一条只能靠读题面对着数据核一遍发现 —— 这也正是每个解析页都转录题面的理由。
3⚠ 三个错法,全长在输出格式上
题面把这句话写了两遍(输出格式一次、提示里又一次),可它在屏幕上是看不见的。
★ 触发条件是一句精确的话:Z 的出现次数小于最大值 ——
只要某一层里 Z 那一列是空的,这一行就带着一个多余空格结束。
⚠ 反过来说:如果 Z 恰好是出现最多的字母,这个 bug 一次都不会现形。
// 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;}点「运行 ▶」看结果
样例里最后一行是完整的 26 个字母 —— 包括一次都没出现过的那些。 ★ 触发条件:存在某个字母一次都没出现。 ⚠⚠ 而官方那组样例恰好是一个全字母句(26 个字母一个不缺,上一步已经数过) ⇒ 它放过这个错法。
for (h = 1; h <= mx; h++) 和 for (h = mx; h >= 1; h--) 只差一个方向。
★ 触发条件:不是每个字母的次数都落在 {0, 最大值} 里 ——
反过来,如果每个出现过的字母都出现同样多次,那么每一层长得一模一样,正着打反着打完全相同。
4★★★ 这一页的主线:对拍一 strip,就有一整个错法凭空消失
// 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 |
-
★★★ 一 strip,「行末空格」那一列就整列变成 0,而另外两个一个不少。 ⇒ 本书从第 26 章 P1040 起反复说的那句「对拍别 strip、要逐字节比」, 在这一页拿到了最锋利的证据:它不是「可能漏一点」,是这一类 bug 整个消失。 ⚠ 而这道题恰恰是最容易让人想去 strip 的那种题 —— 输出里全是空格, 肉眼看两份输出「明明一模一样」。
-
★★★ 档 2 把三个错法一起打成 0,而三个 0 都能不跑程序地证明:
错法 为什么在「每个字母同样多次」上必然对 行末空格 每一层里 Z都有星 ⇒ 每行本来就以*结尾,没有可去的空格漏字母 26 个字母全出现过 ⇒ 一个都不会被跳掉 上下颠倒 所有层长得一样 ⇒ 正着打和反着打逐字节相同 ⇒ 第 33 章 P1266、本章 P1200 那条的第三次: 一个对照档可以同时给三个 0 交代清楚 —— 反过来说, 拿「最整齐」的数据测,这三个 bug 一个都测不出来。
-
★★ 档 0 里「行末空格」和「漏字母」都是 284,可它们的交集只有 268 —— ⇒ ⚠ 两列数字相同,不等于触发线相同 (第 46 章 P2114 那次是真的共用一条线:交集 32、两列都是 32)。 ⇒ 想说「它俩是同一件事」,得把交集数出来。
-
★ 十二格「触发 ≡ 抓获」一个不差(逐字节那张表),三条触发条件各是一句精确的话。
5★ 官方那唯一一组样例:打死两个,放过一个
| ✗ 行末空格 | ✗ 漏字母 | ✗ 上下颠倒 | |
|---|---|---|---|
| 官方样例 | ★ 死 | 放过 | ★ 死 |
★ 那四行文字连起来是一个全字母句(26 个字母一个不缺,第 ② 步数过) ⇒ 「只打出现过的字母」在它上面一个字都不会错。
⇒ ★★ 这是「这组样例在结构上问不出这个问题」的又一次, 而这次那个「结构」有个名字:pangram。 ⚠ 出题人挑一句全字母句当样例,本意多半是「让柱状图好看一点」—— 而它顺手把一个真实的错法藏了起来。
6★ 哪一版就已经能过了
| 版本 | 结果 | 说明 |
|---|---|---|
★ p1598.cpp |
AC | 统计 → 逐层 → rtrim → 字母行 |
★ p1598Word.cpp |
AC | 1200 轮逐字节相同 |
| ✗ 行末空格 | WA | ⚠ strip 之后完全看不见 |
| ✗ 漏字母 | WA | ⚠ 官方样例放过 |
| ✗ 上下颠倒 | WA | 样例就死 |
⇒ ★★ 一句话带走:这道题的算法一句话就写完了,而它值得留下的是一条对拍纪律 —— 逐字节比,别 strip。 这一页把代价量出来了: 一 strip,一整类 bug(284 / 300)当场变成 0。