免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

今年的第几天:日期计算与闰年判断的编程题详解

今年的第几天:日期计算与闰年判断的编程题详解 1. 题目拆解到底在计算什么“KY18 今年的第几天”这题我印象很深。它几乎是每套入门算法题单里的熟面孔各大在线评测平台上都有类似的题号描述也相当简洁给你一个具体的日期让程序算出它是这一年的第几天。乍一看就是个简单的日期换算但真正上手写过的人都知道这道题能拆出不少细节。题目给的信息一般长这样输入一行日期格式可能是2024-03-01也可能是2024 3 1要求输出一个整数表示这天是当年的第几天。比如2024-03-01应该输出 61因为 2024 年是闰年1 月 31 天2 月 29 天加起来 60 天3 月 1 日就是第 61 天。我第一次做这题的时候觉得无非就是取出年、月、日然后拿月份去查表累加。但实际写下来发现有三个地方特别容易翻车闰年判断条件记错、2 月的天数处理不当、月份天数表没有处理好下标。这三个问题单独看都不难凑在一起就会让初学者反复提交反复 WAWrong 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 版本很有意思因为标准库自带LocalDateimport 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 三种语言差异对照语言输入解析重点最容易踩的坑Cscanf格式串必须与输入完全匹配数组下标越界月份从 0 开始Ccin/cout忘关同步流数据量大时超时本题无影响Java手动切分输入格式LocalDate封装过度失去原理理解Pythonsplit(-)后转 int列表切片左闭右开负索引问题这四行对应的坑都是我实际踩过或者帮别人排查过的。它们和“闰年规则”无关但决定了你的代码能不能在评测机器上跑出正确结果。刷题时越早重视语言层面的细节后面做复杂题目时越省心。5. 边界测试把隐藏的坑全部挖出来我一直认为一道题的 AC 率不仅取决于主逻辑写得好不好还取决于你这道题到底准备了多少测试用例。尤其像“今年的第几天”这种数字计算题边界条件一旦踩中基本就是 WA 或者 RE。5.1 一组必测的用例我每次给这道题写测试都会至少跑下面这些用例输入日期期望输出验证点2024-01-011新年第一天2024-02-2960闰日当天2024-03-0161闰年后第一天2023-03-0160平年 3 月 1 日2000-03-0161400 年一闰的特殊年份1900-03-0160百年不闰的特殊年份2024-12-31366闰年最后一天2023-12-31365平年最后一天这些用例覆盖了三种分支普通月份、闰年 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 日输出 592 月 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这几个特殊用例跑一遍确认输出符合预期。做完这些这道题就已经不是“刷过”而是“吃透”了。以后遇到类似的日期题比如“某天是星期几”“两个日期相隔几天”“给定第几天反推日期”你都可以直接复用这套思路——先处理闰年再处理月份天数表最后套前缀和或循环遍历。日期类问题的路数其实就这么简单。
返回列表