水题不水:四道 OJ 入门题里的深坑
- [🎯 前言](#🎯 前言)
- [一、整数除法的陷阱:苹果和虫子(洛谷 P5709)](#一、整数除法的陷阱:苹果和虫子(洛谷 P5709))
- [二、逻辑运算符的三种写法:数的性质(洛谷 P5710)](#二、逻辑运算符的三种写法:数的性质(洛谷 P5710))
-
- 题目简述
- 写法一:三元运算符
- 写法二:直接输出布尔表达式
- [写法三:布尔代数(干掉 count 变量)](#写法三:布尔代数(干掉 count 变量))
- [三、同一道题的 8 种写法:月份判断(牛客 NC214428)](#三、同一道题的 8 种写法:月份判断(牛客 NC214428))
- [四、不开 long long 见祖宗:角谷猜想(洛谷 B2077)](#四、不开 long long 见祖宗:角谷猜想(洛谷 B2077))
- 五、深度思考
-
- [深度思考 1:`(s+t-1)/t` 的数学本质是什么?](#深度思考 1:
(s+t-1)/t的数学本质是什么?) - [深度思考 2:C++ 短路求值的本质------为什么 `&&` 能省掉计算?](#深度思考 2:C++ 短路求值的本质——为什么
&&能省掉计算?) - [深度思考 3:8 种写法背后,其实是三种"程序控制抽象"的进化](#深度思考 3:8 种写法背后,其实是三种"程序控制抽象"的进化)
- [深度思考 4:int 溢出的底层发生了什么?](#深度思考 4:int 溢出的底层发生了什么?)
- [深度思考 5:防御性编程------为什么 `if (0 == t)` 比 `if (t == 0)` 安全?](#深度思考 5:防御性编程——为什么
if (0 == t)比if (t == 0)安全?)
- [深度思考 1:`(s+t-1)/t` 的数学本质是什么?](#深度思考 1:
- [📝 总结](#📝 总结)
🎯 前言
这是我「算法竞赛刷题笔记」系列的第一篇。最近在刷洛谷和牛客的入门题,发现一个奇怪的事:题目看起来巨简单,我一眼就知道怎么做,但一交题------WA、TLE、RE 轮番上阵。后来仔细看了别人的题解才意识到,这些"水题"考的从来不是算法,而是你对 C++ 基础的那些**"以为自己懂了其实没懂"**的细节。这篇笔记就把我踩过的 4 个坑、以及从坑里挖出来的思想,一股脑记下来。
一、整数除法的陷阱:苹果和虫子(洛谷 P5709)
题目简述
一堆苹果共 m 个,虫子每 t 小时吃 1 个,过了 s 小时后还剩几个完好的苹果?
看起来很简单:被吃的个数 = s / t,剩余 = m - s / t。
但这是错的。
我踩的坑
C++ 的整数除法向下取整 。比如 9 / 4 = 2,但虫子已经开始吃第 3 个 了,这个苹果就不算"完好"。所以应该向上取整。
正解与推导
向上取整的整数公式是 (s + t - 1) / t。拆开理解:
| 情况 | s, t | 向下取整 | 向上取整 | 公式结果 |
|---|---|---|---|---|
| 整除 | 8, 4 | 2 ✅ | 2 | (8+3)/4 = 11/4 = 2 |
| 有余数 | 9, 4 | 2 ❌ | 3 | (9+3)/4 = 12/4 = 3 |
原理:只要余数 ≥ 1,加 t-1 就会进位;余数 = 0 时,加 t-1 仍不够 t,不进位。
完整代码
cpp
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
int m, t, s;
cin >> m >> t >> s;
if (t == 0) { // 防除零异常
cout << 0 << endl;
return 0;
}
int eaten = (s + t - 1) / t;
int remain = m - eaten;
cout << max(0, remain) << endl; // 剩余不能为负
return 0;
}
💡 防御性编程小习惯 :
if (t == 0)的写法,老程序员更喜欢if (0 == t)------这样万一手滑把==写成=,编译器会直接报错,帮你避免隐蔽 Bug。
二、逻辑运算符的三种写法:数的性质(洛谷 P5710)
题目简述
给一个数 x,输出四个人的喜好:
- A 喜欢:同时是偶数 且 在 (4, 12] 范围内
- Uim 喜欢:至少满足一个条件
- B 喜欢:恰好满足一个条件
- 正妹喜欢:两个都不满足
设两个布尔条件:c1 = (x%2==0)、c2 = (x>4 && x<=12)。题目本质上是把 c1 和 c2 的四种组合(真真、真假、假真、假假)映射到四个输出。
写法一:三元运算符
cpp
cout << (count == 2 ? 1 : 0) << " ";
把
if...else压成一行表达式。适合简单逻辑,日常写代码最常用。
写法二:直接输出布尔表达式
cpp
cout << (count == 2) << " "; // true → 1, false → 0
这是 C++ 的特性:
bool输出时自动转成 1/0。注意括号不能省 ,否则cout的优先级会让表达式变成先输出count再和 2 比较,结果完全错。
写法三:布尔代数(干掉 count 变量)
cpp
bool isEven = (x % 2 == 0);
bool isInRange = (x > 4 && x <= 12);
cout << (isEven && isInRange) << " "; // A:都满足
cout << (isEven || isInRange) << " "; // Uim:至少一个
cout << (isEven != isInRange) << " "; // B:恰好一个
cout << (!isEven && !isInRange) << endl; // 正妹:都不满足
这一层最高阶 。它把题目彻底抽象成了数学逻辑------
!=就是异或(XOR),两边不同为真,恰好表达"恰好满足一个"。
三、同一道题的 8 种写法:月份判断(牛客 NC214428)
题目简述
输入一个数字(年份后两位+月份),取后两位作月份,输出对应季节(spring/summer/autumn/winter)。
这题我见过最狠的题解:同一道题,8 种写法。从"能跑就行"到"炫技",层层递进。
第一梯队:日常推荐
写法1:if-else if ------ 最符合人类直觉。
写法2:switch-case 穿透 ------ 利用不写 break 向下穿透的特性:
cpp
case 3: case 4: case 5:
cout << "spring" << endl; break;
编译器会优化成跳转表(Jump Table),效率极高。
第二梯队:数据结构与映射
写法3:数组查表(打表法) ------ 下标即月份:
cpp
string seasons[13] = {"", "winter","winter","spring","spring","spring",
"summer","summer","summer","autumn","autumn","autumn","winter"};
cout << seasons[month];
O(1) 时间,没分支,改动季节规则只改数组。
写法4:std::map 映射 ------ 像查字典,工程代码最易扩展。
第三梯队:数学与底层技巧
写法5:数学映射 ------ (month % 12) / 3 直接把月份映射到 0/1/2/3:
cpp
string seasons[4] = {"winter", "spring", "summer", "autumn"};
cout << seasons[(month % 12) / 3];
零条件判断,纯数学。
写法6:函数指针数组 ------ 把"输出"动作打包成函数,下标直接调用:
cpp
void (*actions[13])() = {nullptr, winter, winter, spring, spring, spring, ...};
actions[month](); // 直接调用对应月份的函数
写法7:三元运算符嵌套 ------ 把 if-else 压成一行:
cpp
cout << (m>=3 && m<=5 ? "spring" : (m>=6 && m<=8 ? "summer" : (m>=9 && m<=11 ? "autumn" : "winter")));
代码行数最少,但可读性最差,真实工程别这么写。
写法8:位掩码(Bitmask) ------ 把月份看作 12 个二进制位:
cpp
int SPRING_MASK = (1<<3) | (1<<4) | (1<<5);
if (SPRING_MASK & (1 << month)) cout << "spring";
极致速度,牺牲可读性。高并发服务器里常见。
选择建议:考试写 if-else;追求简洁用 switch 或打表;工程用 map;想炫技用函数指针/位掩码。
四、不开 long long 见祖宗:角谷猜想(洛谷 B2077)
题目简述
角谷猜想:对于正整数 n,若偶数则 n = n/2,若奇数则 n = 3n+1,反复直到 n=1,输出所有中间值。
我一眼写出代码,交上去------TLE。
三次提交的教训
| 数据类型 | 最大值 | 结果 | 原因 |
|---|---|---|---|
int (32位有符号) |
约 21 亿 | TLE | 溢出后变负数,-1%2 不等于 1,死循环 |
unsigned int (32位无符号) |
约 42 亿 | WA | 溢出后回绕成错误整数,能跑完但结果错 |
long long (64位有符号) |
约 922 亿亿 | AC ✅ | 完全容纳中间值 |
关键事实 :题目给的 N ≤ 2,000,000,看起来很小,但 704511 在运算过程中会产生 560 多亿的中间值 ------int 根本放不下。
正确代码
cpp
#include <iostream>
using namespace std;
int main() {
long long n; // 必须 long long!
cin >> n;
while (n != 1) {
cout << n << " ";
if (n % 2 == 0) n /= 2;
else n = 3 * n + 1;
}
cout << 1 << endl;
return 0;
}
🔑 竞赛铁律 :看到题目里有乘法 (如
3*n+1)或累加 ,永远先开long long。不要只看输入范围,要预估计算过程中的最大可能值。
五、深度思考
深度思考 1:(s+t-1)/t 的数学本质是什么?
这不是什么魔法公式,而是整数除法的商加一条件的代数等价形式。
对于正整数 s、t,设
s = qt + r(0 ≤ r < t):
- r = 0 时,
(s+t-1)/t = (qt+t-1)/t = q(因为t-1 < t,不够进位)- r ≥ 1 时,
(s+t-1)/t = (qt+r+t-1)/t = q + (r+t-1)/t = q+1(因为r+t-1 ≥ t,进位)更一般地,Python 3 有
//向上取整写法,Java 有Math.ceil(),但 C++ 选择让程序员显式写出取整方式------因为底层整数除法指令只支持截断。这个公式其实是把"取整意图"翻译成 CPU 能执行的截断操作。
深度思考 2:C++ 短路求值的本质------为什么 && 能省掉计算?
&&的语义是:左边为 false 时,右边完全不计算 。这不是"优化",而是语言标准保证的语义。这意味着你可以写
if (ptr != nullptr && ptr->val > 0),第二句在 ptr 为空时根本不会执行。如果去掉短路,空指针解引用就崩了。同理
||左边为 true 时右边不计算。理解这一点后,布尔表达式c1 != c2(异或)的妙处就很清晰:它不是"统计满足个数",而是直接表达"两者不同"------这是数学抽象对暴力 if 的降维打击。
深度思考 3:8 种写法背后,其实是三种"程序控制抽象"的进化
从 if-else 到 switch、查表、函数指针,本质上经历了三次抽象跃迁:
抽象层次 代表写法 核心思想 控制流分支 if-else、switch 用语言的控制结构做决策 数据驱动 数组查表、map 把规则变成数据,代码逻辑不变 行为即数据 函数指针数组 把"动作"本身当作可索引的对象 这种从"写死逻辑"到"数据配置"再到"行为可配置"的演进,在工程里无处不在------状态机、策略模式、插件系统,都是同一个思想的延伸。
深度思考 4:int 溢出的底层发生了什么?
C++ 标准规定,有符号整数溢出是未定义行为(UB),编译器可以做出任何反应。实践中:
- 现代 CPU 使用补码 表示有符号数。
INT_MAX + 1的位模式恰好是INT_MIN,所以 int 溢出会"跳"到负数。- 负数在
while(n != 1)循环里,n%2 == -1(C++ 里负数取模可正可负),不等于 1,走偶数分支n/2,趋向 0,然后0/2永远是 0------死循环,TLE。unsigned int的溢出是明确定义的环绕行为(wrap around) ,所以不会死循环,但计算结果是错的------WA。long long是 64 位,最大值约 9.22×10¹⁸,远超本题中间值的上限,所以AC。这解释了为什么竞赛圈有句玩笑:"不开 long long 见祖宗"------因为 int 溢出的表现太诡异了。
深度思考 5:防御性编程------为什么 if (0 == t) 比 if (t == 0) 安全?
1990 年代 NASA 的软件规范里就强制要求这种写法(叫 Yoda Conditions )。核心原因:C/C++ 里
=和==长得太像,手滑是高频错误。
if (t = 0)→ 编译通过,t 被赋值为 0,条件永远为假,静默 Bug。if (0 = t)→ 编译报错,左值不可赋值。现代编译器(gcc -Wall、clang -Wparentheses)已经能检测
if (t = 0)并报警告,所以这个习惯在工程上不是必须的。但在竞赛这种没有编译警告、一交就挂的场景下,它依然是零成本的保险。
📝 总结
这四道题表面看是入门题,但它们教我的东西远不止语法:
- 整数除法不"智能"------它只是截断,向上取整要靠公式
- 布尔表达式是数学 ------
!=就是异或,&&||是代数运算 - 同一逻辑有多种抽象层次------从 if-else 到位掩码,选择取决于场景
- 数据类型是程序员的承诺------int 放不下就说放不下,别等溢出教你做人
- 防御性编程是习惯 ------
0 == t、long long优先、边界特判,这些小习惯在赛场上是救命的
下次再遇到"水题",我会多想一步:这题在考我什么细节?
点个赞收藏一下呗~有理解不对的地方欢迎评论区指正
参考资料:
- 洛谷 P5709、P5710、B2077 题目页
- 牛客竞赛 NC214428
- 《C++ Primer》第 4 章 表达式
- 《CSAPP》第 2 章 信息的表示和处理
标签: C++ 算法竞赛 洛谷 牛客 溢出 逻辑运算 打表 刷题笔记