1. 题目拆解:到底在计算什么
“KY18 今年的第几天?”这题我印象很深。它几乎是每套入门算法题单里的熟面孔,各大在线评测平台上都有类似的题号,描述也相当简洁:给你一个具体的日期,让程序算出它是这一年的第几天。乍一看就是个简单的日期换算,但真正上手写过的人都知道,这道题能拆出不少细节。
题目给的信息一般长这样:输入一行日期,格式可能是2024-03-01,也可能是2024 3 1,要求输出一个整数,表示这天是当年的第几天。比如2024-03-01应该输出 61,因为 2024 年是闰年,1 月 31 天,2 月 29 天,加起来 60 天,3 月 1 日就是第 61 天。
我第一次做这题的时候,觉得无非就是取出年、月、日,然后拿月份去查表累加。但实际写下来发现有三个地方特别容易翻车:闰年判断条件记错、2 月的天数处理不当、月份天数表没有处理好下标。这三个问题单独看都不难,凑在一起就会让初学者反复提交反复 WA(Wrong Answer)。
所以这篇文章我想从题目本质开始,把闰年规则、两种经典实现、多语言写法、边界测试、再到它的常见变体,完整过一遍。无论你是刚接触编程题的新手,还是准备校招刷题时想把这题做得滴水不漏的求职者,这篇文章都能给你一个比“能 AC”更完整的视角。
1.1 日期类问题的两个核心要素
处理“第几天”这类问题,本质上只需要回答两个问题:一是今年到底有多少天,二是当前月份之前已经积累了几天。
第一个问题涉及闰年规则:每 4 年一闰,但每 100 年不闰,每 400 年又要闰。这套规则直接决定了 2 月是 28 天还是 29 天,进而影响一整年的天数。平年是 365 天,闰年是 366 天,相差的这 1 天完全由 2 月承担。
第二个问题需要一张月份天数表。常规写法是:
int days_in_month[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};这里故意把下标 0 的位置空出来,让下标和月份对应:days_in_month[1]是 1 月的天数,days_in_month[12]是 12 月的天数。这个设计看似多占了一个元素,却避免了“月份减一”的额外操作,写起来更符合直觉。
有了这两个要素,计算思路就很清晰了:先把当年每个月的天数累加,加到目标月份的前一个月,最后再加上目标月份的“日”,就是结果。
1.2 一个容易被忽略的前提:输入合法吗
题目没有明确说明输入是否一定合法,这是很多实现里隐藏的分歧点。严谨的做法是判断月份是否在 1 到 12 之间、日期是否超出当月天数;而在线评测平台上,绝大多数测试数据都是合法的,所以不少人选择不校验。
但从工程角度我建议在本地练习时加上校验,因为实际业务里你不可能假设上游数据永远干净。校验逻辑也不复杂,核心就是根据月份查表,然后看日期是否越界。唯一的特殊情况仍然是 2 月:先判断闰年得到days_in_month[2],再进行日期校验。
2. 闰年判断:这题最容易翻车的地方
我见过很多初学者在闰年判断上栽跟头,包括当年的我。最典型的错误是只写year % 4 == 0,然后洋洋得意地提交,结果 1900 年这类特殊年份狠狠打脸。
2.1 完整规则与常见错误写法
正确的闰年规则是:能被 4 整除的年份是闰年,但如果它同时能被 100 整除,就不是闰年;可如果它还能被 400 整除,那它仍然是闰年。
翻译成代码,推荐写成:
bool is_leap(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); }很多人容易把它写错成:
bool is_leap(int year) { return year % 4 == 0 || year % 100 != 0; }这种写法的问题在于:任何不能被 100 整除的年份都被当成闰年了,比如 2023。因为year % 4 == 0为假,但year % 100 != 0为真,整个表达式结果就是 true,于是任何奇数年份都会变成闰年,属于逻辑上的硬伤。
还有另一些人会写:
bool is_leap(int year) { return year % 4 == 0 && year % 100 != 0 && year % 400 == 0; }这个的问题更隐蔽,它把三个条件用“与”连接,最终只有能被 400 整除的年份才会返回 true,比如 2000 是闰年,但 2024 却被判成平年。原因很简单:year % 100 != 0和year % 400 == 0这两个条件本身是互斥的。
每次看到这两种错误写法我都想强调:闰年判断不是单纯的“四年一闰”,而是“四年一闰、百年不闰、四百年再闰”。判断时是“与”和“或”的组合,不是全部用“与”也不是全部用“或”。
2.2 为什么一年 365 天还要多出 0.2422 天
如果你问为什么闰年规则搞得这么绕,就得回到天文背景:地球绕太阳一周的回归年大约是 365.2422 天,并不是整数。如果每年按 365 天算,四年就会累计多出约 0.9688 天,所以大约每四年补上一天,这就是闰年的由来。
但 0.2422 天乘以 4 并不等于完整的 1 天,多补了一点,所以用“百年不闰”来修正,让 1700、1800、1900 这些年份不设闰日。不过这样修正后仍然存在微小误差,于是又加了一条“四百年再闰”,让 1600、2000 这些年份恢复闰年。这套规则正是公历中“置闰”的实际逻辑。
说实话,这些背景知识本来不是刷题必需,但了解了之后你就不会死记硬背条件。以后再遇到类似的日期类题目,比如计算两个日期之间相差多少天、给定年份的第几天反推日期,你都能第一时间反应过来:核心就是闰年和月份天数的组合问题。
2.3 验证闰年判断的方法
闰年判断写完后,我建议你用下面几个年份测试一下:
| 年份 | 期望结果 | 判断依据 |
|---|---|---|
| 2000 | 闰年 | 能被 400 整除 |
| 2024 | 闰年 | 能被 4 整除且不能被 100 整除 |
| 2023 | 平年 | 不满足任何闰年条件 |
| 1900 | 平年 | 能被 100 整除但不能被 400 整除 |
| 2100 | 平年 | 能被 100 整除但不能被 400 整除 |
这些测试用例精准覆盖了闰年规则的所有分支。如果你用一个简单的for循环批量打印is_leap的结果,就能很直观地看到 2000 和 1900 之间的差异,也能帮助你把这条规则彻底记牢。
3. 两种实现路线:逐月模拟与查表法
闰年问题解决了,接下来就是“把月份天数加起来”的环节。这个环节看起来幼稚,实际上还有两种主流做法:一种是循环累加,一种是直接查前缀和表。两种做法都能通过,但性能、代码量和可读性各有优劣。
3.1 路线一:逐月累加,思路最直接
逐月累加的代码长这样:
#include <iostream> using namespace std; bool is_leap(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } int main() { int year, month, day; scanf("%d-%d-%d", &year, &month, &day); int days_in_month[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (is_leap(year)) { days_in_month[2] = 29; } int ans = 0; for (int i = 1; i < month; i++) { ans += days_in_month[i]; } ans += day; cout << ans << endl; return 0; }这个方案的优点是容易理解,也是我推荐给新手的版本。循环从 1 月加到month - 1月,最后把天数加上。days_in_month[2]提前根据闰年情况改为 29,后续所有逻辑都不需要再关心闰年。
有人会问:为什么不直接在循环里判断i == 2 && is_leap(year)?也可以,但每次循环都做一次月份判断,会增加不必要的分支。提前把 2 月的天数定好,循环体内只剩下纯粹的累加,逻辑上更干净。
3.2 路线二:前缀和查表,一步到位
如果你追求更高的执行效率,尤其是想写出“连一次循环都不用”的版本,可以用前缀和表提前算好“某个月之前的天数总和”:
int pre_sum[] = {0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};pre_sum[m]表示第 m 个月之前的总天数。比如pre_sum[3] = 59,就是 1 月和 2 月之和(31 + 28)。计算时直接取pre_sum[month] + day,然后如果月份大于等于 3 且是闰年,再加 1。
完整实现:
#include <cstdio> bool is_leap(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } int main() { int year, month, day; scanf("%d-%d-%d", &year, &month, &day); int pre_sum[] = {0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; int ans = pre_sum[month] + day; if (month > 2 && is_leap(year)) { ans += 1; } printf("%d\n", ans); return 0; }这段代码最妙的地方在于它把闰年的影响收敛到了一个非常小的分支里:只有月份超过 2 月时,闰年才需要额外补一天。如果输入是 1 月或 2 月的日期,连闰年判断都不需要做。因为闰年影响的是 2 月的长度,而 2 月之后的所有月份都会因为 2 月多了一天而顺延一天。
3.3 两种方案的取舍
在性能上,查表法明显更快,因为它的时间复杂度是 O(1),而逐月累加是 O(month)。但题目规模通常只有几十个测试用例,这点性能差异完全感知不到。
我更推荐的做法是:初学阶段用循环模拟法,把“遍历月份天数”这件事想透彻;等这道题 AC 之后,再改成查表法,体会“用预处理空间换时间”的思想。这两种思路在后面的日期类题目中都会反复用到。比如“给定年份和第几天,推算具体日期”这种逆问题,查表法的前缀和数组反过来用就非常顺手。
4. 多语言实现与细节差异
“今年的第几天”几乎每个主流语言都有对应的题解,但不同语言的细节处理方式很不一样。尤其是 C/C++、Java、Python 这三种最常用语言,它们处理输入输出的方式差异很大,写不好就会出现“本地运行正常,提交后 RE(运行时错误)”的尴尬情况。
4.1 C 语言:数组下标与scanf格式要小心
C 语言版本需要注意两个问题。第一个是数组下标:我见过有人用int days_in_month[12] = {31, 28, ...},然后通过days_in_month[month - 1]来访问,确实也能做,但每次访问都要做一次减法,而且月份为 1 时容易产生混乱。我更推荐把数组长度声明为 13,让下标直接对准月份。
第二个问题是scanf的格式串。如果输入格式是2024-03-01,就必须写成scanf("%d-%d-%d", &year, &month, &day),三个整数之间用-分隔。很多新手在这里喜欢写成%d-%d-%d之外的格式,导致输入解析失败。要确认格式是否正确,可以在本地故意打印出 year、month、day 三个变量,看看有没有正确分割。
4.2 C++ 与 Java:面向对象思路带来的取舍
C++ 版本和 C 语言在核心上完全一致,差别主要在于可以用std::cin来读输入,代码更简洁。但有个细节值得注意:scanf在解析大量整数时比cin快,虽然本题体现不出来,但如果你以后打 ACM 比赛,数据量大时cin和cout不关同步流,很容易超时。
Java 版本很有意思,因为标准库自带LocalDate:
import java.time.LocalDate; public class Main { public static void main(String[] args) { LocalDate date = LocalDate.of(2024, 3, 1); int dayOfYear = date.getDayOfYear(); System.out.println(dayOfYear); } }这个版本一行就算出结果,看起来十分优雅。但我不建议在练习阶段用这种“作弊”写法,因为LocalDate已经把闰年和月份累加全部封装好了,你完全理解不到题目背后的逻辑。很多校招面试官问这道题时,想听的并不是你会不会调用getDayOfYear(),而是能不能手写闰年判断和天数累加,以此考察基本功。
如果你在笔试环境里用 Java,可以先用LocalDate快速验证结果对不对,再手写一遍核心逻辑。这种“先确认期望值,再手工实现”的方式在调试时很高效。
4.3 Python:动态类型带来的灵活与陷阱
Python 版本的优势是代码简短,劣势同样明显:因为不需要声明类型,月份天数的数组类型不会有人帮你检查。比如写days_in_month = [0, 31, 28, 31, ...]时不小心把 30 写成字符串"30",运行时会直接暴露出类型错误,但在 C 语言里编译器会早早拦住你。
Python 另一个特殊的地方是列表可以负数索引。如果你用days_in_month[month - 1]且month不小心为 0,得到的是列表的最后一个元素,程序可能不会崩溃而是返回一个完全错误的天数。这种错误在调试时极其隐蔽,我会在后面的“边界测试”部分专门讲怎么避开。
Python 的标准实现:
def is_leap(year): return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0) def day_of_year(year, month, day): days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] if is_leap(year): days_in_month[2] = 29 return sum(days_in_month[1:month]) + day year, month, day = map(int, input().split('-')) print(day_of_year(year, month, day))注意sum(days_in_month[1:month])这一行,Python 的切片是左闭右开的,也就是说[1:3]会取下标 1 和 2 的元素,正好是 1 月和 2 月的天数。这个语法和 C 语言的循环边界i < month本质上是一回事,但很多 Python 新手会被切片的两端规则绕晕,觉得明明取到了 3 个月,可实际只有 2 个月。如果发现结果恒差一个月的天数,优先检查切片边界。
4.4 三种语言差异对照
| 语言 | 输入解析重点 | 最容易踩的坑 |
|---|---|---|
| C | scanf格式串必须与输入完全匹配 | 数组下标越界,月份从 0 开始 |
| C++ | cin/cout忘关同步流 | 数据量大时超时(本题无影响) |
| Java | 手动切分输入格式 | LocalDate封装过度,失去原理理解 |
| Python | split('-')后转 int | 列表切片左闭右开,负索引问题 |
这四行对应的坑,都是我实际踩过或者帮别人排查过的。它们和“闰年规则”无关,但决定了你的代码能不能在评测机器上跑出正确结果。刷题时越早重视语言层面的细节,后面做复杂题目时越省心。
5. 边界测试:把隐藏的坑全部挖出来
我一直认为,一道题的 AC 率不仅取决于主逻辑写得好不好,还取决于你这道题到底准备了多少测试用例。尤其像“今年的第几天”这种数字计算题,边界条件一旦踩中,基本就是 WA 或者 RE。
5.1 一组必测的用例
我每次给这道题写测试,都会至少跑下面这些用例:
| 输入日期 | 期望输出 | 验证点 |
|---|---|---|
| 2024-01-01 | 1 | 新年第一天 |
| 2024-02-29 | 60 | 闰日当天 |
| 2024-03-01 | 61 | 闰年后第一天 |
| 2023-03-01 | 60 | 平年 3 月 1 日 |
| 2000-03-01 | 61 | 400 年一闰的特殊年份 |
| 1900-03-01 | 60 | 百年不闰的特殊年份 |
| 2024-12-31 | 366 | 闰年最后一天 |
| 2023-12-31 | 365 | 平年最后一天 |
这些用例覆盖了三种分支:普通月份、闰年 2 月前后、特殊世纪年份。如果你的程序能在这些用例上全部输出正确结果,那么基本可以放心交卷。
我自己在实际调试时发现,最容易被忽略的是2000-03-01和1900-03-01这两个用例,因为它们都涉及“能被 100 整除,但不一定被 400 整除”的情况。如果闰年判断写成了year % 4 == 0 && year % 100 != 0,完全没考虑 400 年一闰,那么 2000 年就会算错一天,这题就直接 WA 了。
5.2 非法输入的防御处理
上面这些用例都是合法输入。如果输入非法,比如2023-02-29、2023-13-01,程序应该怎么办?在 OJ 上通常不需要处理,因为评测数据保证合法。但在工业级代码里,日期校验是刚需。
一个轻量级的校验思路是:读完日期后先判断month是否在 [1, 12] 区间内,再根据days_in_month[month]判断day是否越界。只要把is_leap的结果并入天数表,正确处理 2 月,整个校验逻辑就是完善的。
如果你用 Java 的LocalDate.of(year, month, day),非法日期会直接抛DateTimeException,等于框架帮你校验了。这也是LocalDate在工程中的价值,只不过刷题时我们不需要它。
5.3 测试时的调试心态
写这道题出现 WA 时,很多人第一反应是“把年份改成 2024 试试”,这个思路太过随机。我更推荐的做法是:先固定年份和月份两个变量,只改变“日”这个变量,对比输出是否平滑递增。比如 2024 年 2 月的输出依次是 32、33、一直到 60,如果 2 月 28 日输出 59,2 月 29 日输出 61,说明中间跳过了 60,那问题一定出在闰日当天是否被累计。
这种“变量隔离法”在调试所有日期类题目时都很好用。它本质上是一种控制变量法,比毫无方向地打印中间结果要有效得多。
6. 变体扩展:从“第几天”到“第几号”
这类日期问题还有几个经典变体,我觉得值得一并说说,因为它们能让这道“入门题”的价值成倍放大。最常见的是“逆问题”:给定年份和这一年中的第几天,反推出具体的月份和日期。
6.1 逆问题的实现思路
正向问题是累加月份的 days,逆问题就是反向查表。
假设输入年份 2024(闰年),给了第 61 天。那么我们要做的就是遍历月份天数,逐月减去,直到剩余天数不足下一个月的长度。减去 1 月(31 天)后剩 30,这 30 天落在 2 月;2 月有 29 天,30 减完还剩 1,说明已经进入了 3 月,日期是 1 号。
代码可以这样写:
def day_to_date(year, n): days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] if is_leap(year): days_in_month[2] = 29 month = 1 while n > days_in_month[month]: n -= days_in_month[month] month += 1 return month, n这一步的难点在于循环终止条件的确定:是while n > days_in_month[month]还是while n >= days_in_month[month]?我经常看到有人在这个边界上出错。判断的方法是:当n刚好等于当月天数时,说明这一天正好是当月的最后一天,日期应为month月n日,而不应该继续减到下个月。所以循环条件只能是n > days_in_month[month],绝不能是>=。
6.2 正向与逆向代码的统一视角
如果你把正向和逆向代码放在一起看,会发现它们都在操作同一个月份天数表。正向从month倒退累加,逆向从累加天数倒退month,本质上是同一张表的两种遍历方式。
理解了这一层,以后再遇到“两个日期相差多少天”“某日期是星期几”等问题,你只需要在此基础上增加“从公元元年到输入日期的总天数”这个前缀和概念,就能把整个日期计算体系串起来。很多看似复杂的日历题,核心都是闰年加上这个前缀和思想。
6.3 另一种变体:不用 if 的查表技巧
有人在追求极致的代码简洁度时,会把闰年对 2 月天数的影响直接融合进表中。比如开两张表,平年一张,闰年一张:
int common_month[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; int leap_month[] = {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};然后根据is_leap(year)选择使用哪张表。这种写法的优点是主逻辑完全不用关心闰年,缺点是表多了一份,代码略冗余。在竞赛中偶尔能看到这种写法,主要是为了在一行表达式里完成整个计算。实际面试时我还是建议用普通的 if 写法,因为可读性更好,面试官更容易看懂你的思路。
7. 我实际刷这道题的经验与建议
这道题我前前后后写过不下五遍,C 语言写过,Python 写过,Java 也写过。每次都有一点新体会。
第一遍是在学校机房里,当年用的是 Visual C++ 6.0。我犯的经典错误是把月份数组从 0 开始,结果访问days_in_month[month]时总差一个月,后来自己在纸上列了一遍下标才知道问题出在哪。那时起我就养成了一个习惯:涉及到下标映射的数字题,先在注释里写清 “第几个元素代表第几个月”,再动手写循环。
第二遍是刷 OJ 时遇到的,题目把输入格式换成了YYYY MM DD这种空格分隔。我因为在scanf格式串里写了-而 WA 了三次。从那次以后,我每次做日期题,第一件事不是写算法,而是仔仔细细看输入格式到底是用空格、斜杠还是减号分隔。这听起来很笨,但确实能省下大量无谓的提交次数。
第三遍是在准备笔试时,我把这道题和它的逆问题放在一起练。这时才真正领悟到那句“逆向查表”的巧妙,也理解了为什么很多代码里会同时维护days_in_month和pre_sum两张表,因为前者服务于正向、后者服务于逆向,两张表组合起来就能应对几乎所有日期题。
如果你现在刚开始刷题,我的建议很简单:先用循环模拟法把这题 AC,再改成查表法。然后手动把2024-02-29、1900-03-01、2000-03-01这几个特殊用例跑一遍,确认输出符合预期。做完这些,这道题就已经不是“刷过”而是“吃透”了。
以后遇到类似的日期题,比如“某天是星期几”“两个日期相隔几天”“给定第几天反推日期”,你都可以直接复用这套思路——先处理闰年,再处理月份天数表,最后套前缀和或循环遍历。日期类问题的路数,其实就这么简单。