我最早刷到这道“基础题-12-求第几天”的时候,心里想的是:这不就是输入年月日输出第几天吗,有什么好写的。结果在提交区看到好几个同学在问“为什么我的答案总是少一天”“2月怎么处理才对”,我才意识到,这道题其实是一块很标准的试金石,专治各种粗心和基础不牢。它的考点不算刁钻,但覆盖了日期类题目的三个核心基本功:闰年判断、月份天数映射、边界情况处理。如果你能一次写对,说明这几个点你是真会了;如果写不对,那今天这篇就当陪你把这个坑一步一步踩平了。
1. 题目本身极简,真正的坑藏在闰年和边界里
1.1 先把题意翻译成程序逻辑
题目描述一句话就能讲完:给定一个年月日,计算这一天是这一年的第几天。比如输入 2023 年 3 月 1 日,因为 1 月 31 天,2 月 28 天,所以答案是 31 + 28 + 1 = 60,输出 60 就对了。
程序要做的事其实只有三步:
- 读入三个整数 y、m、d;
- 把从 1 月到 m-1 月的天数全部加起来;
- 最后再加上本月的天数 d,输出总和。
这逻辑听起来人畜无害,实际一写就出事。最典型的就是把 2 月当成永远 28 天,或者干脆把当前月份的天数也加进去了。比如有人写了一个循环,从 1 月加到 m 月,再把 d 加上,输入 2023 年 1 月 1 日的时候,他算出 31 + 1 = 32,直接多了 31 天。这种错误你自己盯自己的代码往往盯不出来,因为思路一旦跑偏,眼睛会跟着思路走。
所以第一步不是写代码,而是把“累加 months[1] 到 months[m-1],最后加 d”这个公式写在草稿纸上,确认自己没有把当月算进去、没有把 d 重复累加。别小看这一步,它能帮你省掉好几次无谓的 Wrong Answer。
1.2 两个隐藏考点:月份天数和“第几天”的定义
另一个容易阴沟翻船的点是月份天数表记不牢。很多人脑子里只有“一三五七八十腊,三十一天永不差”,但一落到代码里就含糊了。尤其 4 月、6 月、9 月、11 月是 30 天,2 月最特殊,平年 28 天、闰年 29 天。如果这几个数字记错,题目输出差的就不是一天两天了。
再一个概念上的小坑:这道题问的是“第几天”,所以 1 月 1 日答案是 1,而不是 0。有些同学可能是受编程里“下标从 0 开始”的习惯影响,总觉得第一天应该算 0,结果整个答案全部少 1。这个和数组下标无关,它就是一个朴素的日历计数问题,你只需要老老实实把已经过去的天数加 1 就行。
把这两点想清楚,等于地基打好了,后面不管用什么写法都不容易跑偏。
2. 闰年判断:一个条件定生死,背错公式就全错
2.1 为什么“四年一闰”是不完整的
闰年判断是这道题的核心,也是大家错得最集中的地方。最经典的理解是“能被 4 整除就是闰年”,这句话在日常生活中够用,但在题目里不够严谨。因为公历的闰年规则是:能被 4 整除但不能被 100 整除,或者能被 400 整除。
用 C++ 写出来就是:
bool isLeap(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); }这个公式我建议直接背下来,不用每次现推。重点是理解它为什么要多一个“不能被 100 整除”的限定:因为 1900 年能被 4 整除,但它是平年,2 月只有 28 天。如果不加这个限定,1900 年 3 月 1 日就会被你算成第 61 天,而正确答案是 60 天。很多 OJ 的测试数据就是喜欢挑这种整百年份来卡人,因为初学者最容易在“四年一闰”的直觉上翻车。
2.2 闰年只影响 2 月,别让其他月份跟着遭殃
闰年带来的唯一变化是 2 月从 28 天变成 29 天,其他月份完全不受影响。所以正确的处理方式是:先按平年的月份天数表累加,如果判断是闰年并且月份大于 2,最后再加 1 天。
注意这里有个极易写错的地方:很多人判断出闰年后,不管三七二十一直接 sum++,结果 1 月和 2 月也被多加了一天。输入 2024 年 1 月 15 日的时候,正确答案是 15,他却输出 16。这类错误我自己在帮别人看代码时碰到过好几次,几乎都是同一个原因:闰年加一天的条件写成了 if (isLeap(y)),而不是 if (isLeap(y) && m > 2)。
用大白话总结:闰年的额外 1 天,只有在 2 月过完之后才真正体现出来。你求的是 2 月 29 日当天的第几天,那 31 + 29 = 60,加不加这 1 天都无所谓,因为 2 月 29 日本身就是答案的一部分;但你求 1 月 15 日时,2 月的 29 天还没发生,凭什么提前加?
2.3 一个稳健的代码习惯:把闰年判断封装成函数
很多初学者喜欢把闰年判断直接写在 main 函数里,比如写一个 if-else 判断年份,然后再在另一个 if 里判断月份,二层嵌套下来,代码已经乱成一团。我建议把闰年判断单独拎出来,写成上面的 isLeap 函数。原因很简单:
- 逻辑单一,测起来方便;
- 后面做更复杂的日期题时,比如日期差、星期几,这个函数可以直接复用;
- 代码阅读起来一眼能看出意图。
这不算过度设计,只是一种很好的习惯。刷题不只是为了 AC,更是为了把基础代码块打磨成可以随时调用的模板。我后面还会专门说模板的事,这里先卖个关子。
3. 三种实现方案,从“能过”到“好看”逐个拆解
3.1 方案一:if 暴力分支,直白但最容易写漏
新手最爱用的写法是拿 if-else 把 12 个月全部列出来:
if (m == 1) { sum = d; } else if (m == 2) { sum = 31 + d; } else if (m == 3) { sum = 31 + 28 + d; } else if (m == 4) { sum = 31 + 28 + 31 + d; } // 后面继续写到 m == 12这种写法能过,但问题很多。首先代码极其冗长,12 个分支写下来,光看就累。其次非常容易漏改闰年分支——m == 3 的时候,普通人脑中会直接写 31 + 28 + d,根本想不起来要不要判断闰年。最后,这种写法几乎没有任何扩展性,换一道题、换个输入格式,整段代码就要推翻重写。
我的评价是:如果哪天真在考场或者比赛里慌了,用它保底没问题;但平时练习,我不建议把它当成第一方案。
3.2 方案二:switch 倒序累加,思路清奇但容易被忽略
还有一种挺有意思的写法,利用 switch 的“贯穿”特性,从 m-1 月份一路加到 1 月:
int sum = d; switch (m - 1) { case 11: sum += 30; case 10: sum += 31; case 9: sum += 30; case 8: sum += 31; case 7: sum += 31; case 6: sum += 30; case 5: sum += 31; case 4: sum += 30; case 3: sum += 31; case 2: sum += 28; case 1: sum += 31; }这个写法的核心是利用了 switch 的 fall-through 特性:当 m 等于 4 时,m - 1 等于 3,程序会从 case 3 开始,一直往下执行到 case 1,把 3 月、2 月、1 月的天数全部加上。所以最后 sum 就是 1 到 m-1 月所有天数和 + d。
它的优点是代码相对紧凑,思路也挺巧妙;缺点是 fall-through 这个语法特性对初学者不友好,很容易让人看懵,而且同样要处理“闰年则多 1 天”的分支。如果你对 switch 的贯穿机制很熟,这个方案可以作为备选;如果不熟,就别在正式提交时冒险了。
3.3 方案三:前缀和数组,我最推荐的写法
真正的推荐方案是开一个月份天数表,然后累加前 m-1 个月:
#include <iostream> using namespace std; bool isLeap(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } int main() { int y, m, d; int daysInMonth[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; while (cin >> y >> m >> d) { int sum = 0; for (int i = 1; i < m; i++) { sum += daysInMonth[i]; } if (isLeap(y) && m > 2) { sum += 1; } sum += d; cout << sum << endl; } return 0; }我来说说这段代码里几个很细节、但非常关键的设计。
第一,为什么 daysInMonth 数组第一个元素是 0?因为我想让下标和月份直接对应:daysInMonth[1] 表示 1 月的天数,daysInMonth[2] 表示 2 月的天数。这样写循环时i < m就不会出现下标混乱。如果你定义成int daysInMonth[12] = {31, 28, ...},那 1 月对应下标 0,循环里就得写成i < m - 1,不仅拗口,还特别容易出错。一个 0 占位符,换来的是整个逻辑的清晰,非常划算。
第二,为什么累加完成后才判断闰年?因为这样顺序最自然:先把平年的月份天数全部算完,如果发现是闰年而且月份确实已经过了 2 月,再补上额外的一天。如果你把闰年判断放在循环之前,会凭空多出很多分支考虑,比如“当前月份是不是 2 月”“累加过程中会不会经过 2 月”,复杂度一下子上升了。
第三,while (cin >> y >> m >> d) 这个循环结构非常重要。很多 OJ 的题目描述会写“输入多组测试数据”,但也有少数题目没写清楚,实际测试时却连续给多行输入。用 while 读入是最稳妥的写法,它会在文件结束符或输入结束时自动退出循环。如果你只写一次 cin,那么多组数据的情况下,你的程序只会处理第一组,后面全部漏掉。
到这里,三种写法放在一起对比,你可以很清楚地看到,数组写法把复杂的分支转换成了一张简单的“查表”动作,这正是数据表驱动思想在基础题里的第一次亮相。以后遇到更复杂的日期题目,你会发现这个思想会反复出现。
4. 提交之前,先用这组边界数据自测一遍
4.1 最容易藏雷的输入:2 月 29 日和整百年份
程序写完之后,建议不要急着点提交,先手动测几个边界数据。日期题有个特点,普通数据看不出问题,但边界数据一上来,错误就藏不住了。我最常用的自测表如下:
| 输入 | 期望输出 | 测试意图 |
|---|---|---|
| 2023 1 1 | 1 | 平年第一天 |
| 2023 12 31 | 365 | 平年最后一天 |
| 2024 1 15 | 15 | 闰年 1 月,不能多加 1 天 |
| 2024 2 29 | 60 | 闰年 2 月 29 日 |
| 2024 12 31 | 366 | 闰年最后一天 |
| 1900 3 1 | 60 | 能被 4 整除但不是闰年 |
| 2000 3 1 | 61 | 能被 400 整除的闰年 |
这几组数据覆盖了最常见的出错点:平年跨年、闰年 2 月、整百年份、闰年 1 月不应加天数。如果你的程序都能通过,那逻辑上基本就稳了。特别提醒 1900 和 2000 这两组,很多人就是栽在这里:只写了year % 4 == 0,导致 1900 被错判为闰年,3 月 1 日输出 61。
4.2 多组输入的本质:循环结构决定你能得多少分
前面提到了 while (cin >> ...),这里再展开讲一下。C++ 里cin >> y >> m >> d这个表达式,在成功读入数据时会返回 cin 对象本身,也就是真正的 bool 值;当读入失败或遇到文件结束时,会返回一个可转换为 false 的值。所以while (cin >> y >> m >> d)的意思是:一直读,读到读不进去为止。
在本地测试多组输入时,Windows 下按 Ctrl+Z 加回车,Linux 或 macOS 下按 Ctrl+D,表示输入结束。很多初学者不知道这一点,在终端里看到光标一直闪烁,以为程序卡死了,其实是在等你结束输入。平台评测时,测试文件会自动在末尾提供结束标志,所以代码里不需要再额外处理。
如果你确定题目就是单组输入,那去掉 while 直接写也不是不行,但问题是很多题目描述里不会写得很明确。为了稳妥,我建议不管三七二十一把 while 写上,反正单组输入时它也只循环一次,没有任何副作用。
4.3 别人踩过最典型的 Wrong Answer 现场
我见过一个同学的代码,逻辑写得还算顺畅,但结果总是比正确答案多 1。查了半天发现,他把 sum 初始化成了 1,然后循环里又从 1 月加到 m-1 月,最后又加 d。等于是把“这一天”连续加了两遍。这种问题肉眼最难发现,因为整个运算过程从头到尾都很“顺”,你不会想到问题出在初始化上。
另一个典型错误是把 daysInMonth 数组长度为 12,然后循环里for (int i = 1; i <= m; i++)。当 m = 12 时,i 会取到 12,但数组下标只有 0 到 11,直接越界。虽然 C++ 的数组越界不一定会立刻崩溃,但它会读到一块未知内存,结果自然变得不可预测。正确做法要么把数组长度定成 13,要么循环条件写i < m,二者选其一,就不会出问题。
你可以把这些错误归成三类:边界多 1 少 1、闰年判断条件写错、数组索引越界。这三类基本覆盖了这道题的大部分 Wrong Answer 原因。提交前针对这三类逐一排查,效率远高于反复盲交。
5. 做完这道题,你该顺手建一个日期类模板库了
5.1 反向题目:已知第几天,怎么求出日期
“求第几天”的反向题目也很常见:给你年份 y 和天数 n,求对应的日期。比如 2024 年的第 60 天是几月几日?答案是 2 月 29 日。
实现思路刚好反过来:先把闰年的 2 月天数改成 29,然后从 1 月开始,逐个减去每个月的天数,直到剩余天数不够减为止。
int y, n; cin >> y >> n; int daysInMonth[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (isLeap(y)) { daysInMonth[2] = 29; } int month = 1; while (n > daysInMonth[month]) { n -= daysInMonth[month]; month++; } cout << y << "-" << month << "-" << n << endl;这段代码里有一个非常容易忽略的细节:如果你直接修改了 daysInMonth[2] = 29,那么这个数组就变成“闰年专用表”了。同一个程序里如果再想用它计算平年日期,就必须先把 2 月改回去,或者重新声明一个临时数组。否则平年会被错误地当成闰年处理。这个坑我在写日期差题目时踩过一次,后来养成了习惯:凡是会修改天数表的函数,都要明确说明它对数组的影响,或者干脆复制一份再改。
5.2 把 isLeap 和 dayOfYear 存成自己的模板函数
刷题刷到后面你会发现,日期类题目其实就那么几种套路,函数名来回就是 isLeap、dayOfYear、daysBetween 这几个。与其每次都从头写,不如在做完这道基础题之后,顺手把自己的“日期工具箱”建起来。
我这里说一个最基础但最常用的两个函数:
bool isLeap(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } int dayOfYear(int y, int m, int d) { int daysInMonth[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; int sum = 0; for (int i = 1; i < m; i++) { sum += daysInMonth[i]; } if (isLeap(y) && m > 2) { sum += 1; } return sum + d; }每次遇到“求某日是第几天”,直接调用 dayOfYear;遇到“日期差”问题,把两个日期各自转换成相对某基准日的总天数,再相减;遇到“星期几”问题,则可以从一个已知的某年某月某日是星期几出发,用天数差对 7 取模。这些高级题目的地基,都是今天这道题里打下的。
5.3 一道基础题带来的算法思维启蒙
我觉得这道题真正让人受益的地方,不是“我会写求第几天了”,而是它让你第一次体会到:怎么把一类问题抽象成可复用的代码块。isLeap 是一个判断条件,daysInMonth 是一张数据表,dayOfYear 是一个操作函数。这三个东西放在一起,几乎可以解决所有相对简单的日期问题。
以后你再做“求日期差”的时候,你会发现最大的难题被拆成了三步:先算两个日期分别离公元 1 年 1 月 1 日多少天,然后一减,就得到相差天数。这里的核心函数就是你今天写的 isLeap、dayOfYear。我们不需要去背什么复杂的数学公式,只需要把基础模块搭稳,上层问题自然一层层解开。
我当时做完这道题之后,顺手把这两个函数存进了自己的代码模板文件,后面刷了十几道日期相关的题目,基本都能在几分钟内套出主逻辑。这就是基础题应有的价值:你提前把路修好了,后面跑起来自然快。
最后再分享一个我平时调试日期题的小习惯:不要只测题目给的样例,而是先自己脑内把四组数据过一遍——平年 2 月末、闰年 2 月末、平年 12 月 31 日、闰年 12 月 31 日。这四组只要对了,这道题基本跑不偏。如果哪天你在日期题上反复 Wrong Answer,也不用慌,回到“闰年是否多加了、当前月是否误加了、数组有没有越界”这三个老地方去查,八成能定位问题。