算法刷题笔记「Day 1|四道 OJ 入门题里的深坑」

水题不水:四道 OJ 入门题里的深坑

  • [🎯 前言](#🎯 前言)
  • [一、整数除法的陷阱:苹果和虫子(洛谷 P5709)](#一、整数除法的陷阱:苹果和虫子(洛谷 P5709))
  • [二、逻辑运算符的三种写法:数的性质(洛谷 P5710)](#二、逻辑运算符的三种写法:数的性质(洛谷 P5710))
  • [三、同一道题的 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) 安全?)
  • [📝 总结](#📝 总结)

🎯 前言

这是我「算法竞赛刷题笔记」系列的第一篇。最近在刷洛谷和牛客的入门题,发现一个奇怪的事:题目看起来巨简单,我一眼就知道怎么做,但一交题------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) 并报警告,所以这个习惯在工程上不是必须的。但在竞赛这种没有编译警告、一交就挂的场景下,它依然是零成本的保险。


📝 总结

这四道题表面看是入门题,但它们教我的东西远不止语法:

  1. 整数除法不"智能"------它只是截断,向上取整要靠公式
  2. 布尔表达式是数学 ------!= 就是异或,&& || 是代数运算
  3. 同一逻辑有多种抽象层次------从 if-else 到位掩码,选择取决于场景
  4. 数据类型是程序员的承诺------int 放不下就说放不下,别等溢出教你做人
  5. 防御性编程是习惯 ------0 == t、long long 优先、边界特判,这些小习惯在赛场上是救命的

下次再遇到"水题",我会多想一步:这题在考我什么细节?

点个赞收藏一下呗~有理解不对的地方欢迎评论区指正


参考资料:

  • 洛谷 P5709、P5710、B2077 题目页
  • 牛客竞赛 NC214428
  • 《C++ Primer》第 4 章 表达式
  • 《CSAPP》第 2 章 信息的表示和处理

标签: C++ 算法竞赛 洛谷 牛客 溢出 逻辑运算 打表 刷题笔记

相关推荐
星恒随风1 小时前
Linux Socket TCP编程(二):逐文件重构多进程、多线程与线程池服务器,深入fork、pthread_create、popen和std::bind
linux·服务器·笔记·学习·tcp/ip
晚风叙码1 小时前
Linux 进程概念(下):程序地址空间与虚拟内存
linux·服务器·算法
流年如夢1 小时前
继承与多态_继承篇
c++·继承·面向对象·c++进阶
小咕聊编程1 小时前
基于Django的个人摄影博客网站设计与开发
后端·python·django
silvamux_2 小时前
微头条爆款体平台适配实测:把交付要求变成可计算的指标
java·线性代数·算法·决策树·矩阵
论文复现现场2 小时前
DeepSeek V4.1 Flash 和 V4 Pro 有什么区别?API 价格、性能与迁移建议
java·服务器·人工智能·python·大模型api·deepseek·deepseek v4.1
沫璃染墨2 小时前
从零入门计算机网络系列(十):HTTP协议进阶——从资源路径解析到状态码与请求头详解
linux·运维·服务器·网络·c++·http
abc light2 小时前
ARM 裸机开发(1)——ARM 体系结构
c语言·arm开发
量化炼金 (CodeAlchemy)2 小时前
【量化研究】美股历史数据获取:Yahoo Finance 转 Zorro 回测格式
python·量化研究·量化交易