资讯动态

C语言闰年判断:规则推导、选择结构与边界处理实战

发布时间:2026/10/16 4:57:11 来源:尧图企业网站定制
每次带新同学入门 C 语言讲到选择结构这一章我都会把闰年判断这道题放在第一个练手位。原因很实在它短到十几行就能写完却能把 if-else 的嵌套层次、逻辑运算符的优先级、取模运算在负数上的行为、输入函数的返回值处理这一串东西一次性全给你抖搂出来。写对了说明你对布尔表达式的组合有了基本感觉写错了错在哪儿基本也能对应到某个具体的语法点上改起来有迹可循。所以这道题不是背一个公式就完事的作业而是一个能反复咀嚼的小样本。下面我按自己平时讲课和带练的顺序把闰年判断背后的算术依据、C 语言里能写出的几种选择结构形态、输入边界的处理方式以及那些看着对其实错的坑一条条摊开讲清楚。1. 从365.2422天说起闰年那三条规则到底是怎么来的1.1 每年多出来的零头四年凑不齐一天地球绕太阳转一圈用的不是整整 365 天而是大约 365.24219 天。如果我们的日历老老实实每年只算 365 天那每过一年日历就比真实季节提前约 0.24219 天十年下来偏了两天多一百年偏了二十多天用不了几百年春节就会跑到夏天去。解决办法很朴素时不常地往日历里塞一个额外的一天把攒下来的零头还回去。这个额外的一天就是闰日所在的年份就是闰年。关键在于塞多少。很多人知道四年一闰却不知道这条经验为什么会在整百年上失效。算一下四年攒下的零头是 4 × 0.24219 ≈ 0.9688 天比一天少那么一点点。也就是说如果每四年无脑补一天我们其实是在多还每四年多还约 0.0312 天。别小看这小数点后三位累积到 100 年多还的量大约是 0.78 天——快到一整天了。这就是整百年要扣掉一次闰日的算术原因四年一闰的做法在百年尺度上会还过头。提示理解到这一层你在写判断条件时才不会把% 100 ! 0当成莫名其妙的规定它是账本上算出来的平衡项。1.2 400年97个闰日三条规则的算术自证再往上一层为什么被 400 整除的整百年又要恢复闰年我们把 400 年当成一个完整的结算周期来算总账。400 年里能被 4 整除的年份有 100 个这 100 个里面能被 100 整除的有 4 个第 100、200、300、400 年按百年不闰要先扣掉然后 400 这个年份又不该扣得加回来一个。于是 400 年真正的闰年数量是100被4整除的年份数减去 4被100整除的整百年加上 1被400整除的那一个结果97 个闰年反过来验证如果 400 年里有 97 个闰年那这 400 年的总天数是 400 × 365 97 146097 天平均每年 365.2425 天。和真实回归年 365.24219 天相比误差是每年 0.00031 天左右大概 3000 多年才会累积差一天。这个精度对日常使用已经完全够用这也是为什么格里高利历能用这么久。顺便说一句有些文献里还提到3200 年再扣一次的提案那是在追求更极致的精度但在 C 语言作业和绝大多数业务代码里我们只实现前三条规则就够了。1.3 把历法规则翻译成C语言的布尔表达式现在把三条规则用自然语言说清楚一个年份是闰年当且仅当它能被4整除且不能被100整除或者能被400整除。注意这里有一个或者它把整百年单独拎了出来。翻译成 C 语言的布尔表达式最标准的写法是int is_leap (year % 4 0 year % 100 ! 0) || (year % 400 0);这个表达式能正确工作靠的是 C 语言的运算符优先级%的优先级高于和!比较高而、!又高于又高于||。所以即使不写括号编译器读出来的分组也和我们期望的一致。但我在课上一直坚持让学生把括号写全理由后面第 4 节会细讲——不是编译器需要是给你自己和后面维护这段代码的人看的。另外要留意的是year % 4 0 year % 100 ! 0这一组和year % 400 0之间是或的关系不是与。写成是最经典的一次错误它会让 2000 年这种本该闰年的年份被判成平年。2. 同一个判断六种写法选择结构在闰年题里的全部形态2.1 双分支 if-else最直白也最容易写错括号入门阶段最先接触的形态就是 if-else 双分支。它的好处是把是闰年和不是闰年两条出口并列摆开逻辑一目了然if ((year % 4 0 year % 100 ! 0) || year % 400 0) { printf(%d 是闰年\n, year); } else { printf(%d 是平年\n, year); }这段代码里有两处细节值得单独拿出来说。第一是外层那对括号它把整个布尔表达式包成一个整体避免在条件里混入其他运算符时被意外改写。第二是花括号。C 语言允许单条语句不加大括号但这样一来将来你在 if 后面补第二行语句时很容易忘记补括号而编译器不会报错程序却悄悄跑偏。我自己的习惯是只要用了 if就无条件加花括号哪怕里面只有一行printf。这个习惯能省掉后期大量的排查时间。2.2 嵌套 if 分层判断让代码顺序和规则顺序一致如果你想让学生真正理解百年不闰、四百年再闰这两条例外的关系嵌套 if 是比一行逻辑表达式更好的教学工具因为它的执行顺序恰好对应规则的优先级排序if (year % 4 ! 0) { /* 第一关就被刷掉一定是平年 */ leap 0; } else if (year % 100 ! 0) { /* 能被4整除但不是整百年闰年 */ leap 1; } else if (year % 400 0) { /* 是整百年再看能不能被400整除 */ leap 1; } else { leap 0; }这种写法的价值在于早期淘汰的思维year % 4 ! 0这一条能筛掉大约四分之三的年份剩下的年份才需要进入第二层、第三层判断。虽然对现代处理器来说这点计算量完全可以忽略但这种先用代价最低、命中率最高的条件做粗筛的思路在写真正的业务校验时是非常有用的比如先判断参数是否为 NULL再判断长度最后才做格式解析。嵌套 if 的代价是层数容易失控我个人建议嵌套深度不超过三层超过就该考虑抽函数了。2.3 逻辑运算符一行流短路求值帮我们省了什么C 语言的和||都有短路特性左边为假时右边不再计算||左边为真时右边不再计算。这一点在闰年判断里其实能省下一些运算。看这个写法if (year % 4 0 (year % 100 ! 0 || year % 400 0)) { /* 闰年 */ }和第一节那个写法相比这个版本先做year % 4 0的检查。如果年份根本不能被 4 整除后面的两次取模就完全不会执行——大约 75% 的输入在这一步就返回了。这就是把高频淘汰条件放在最左边的实际收益。反过来说如果你把year % 400 0放在最左边那么每个输入都要先算一次% 400虽然结果一样但顺序上就没那么讲究了。注意短路求值在 C 语言里不仅是性能问题更是正确性问题。写if (p ! NULL p-value 0)这种代码时如果编译器不做短路程序就会崩溃。不过要提醒一句如果短路放在函数调用上函数里的副作用比如自增、打印也不会发生这一点在调试时经常让人困惑。2.4 三目运算与 switch什么时候该用什么时候纯属折腾三目运算符?:本质上就是一个表达式形式的选择结构用来做二选一赋值时非常紧凑const char *result is_leap_year(year) ? 闰年 : 平年; printf(%d 是%s\n, year, result);它适合那种两边分支都很短、结果直接就是一个值的场景。但如果你在里面嵌套三目比如再套一层判断代码可读性会急剧下降那种写法我是不推荐的。至于switch这里要专门说一句闰年判断其实不适合用 switch。原因是switch只做等值的整数匹配它的case标签必须是编译期常量。而闰年的判定条件是取模运算的结果落在某个集合里尤其能被400整除在400年周期内对应 97 个不同的年份偏移你不可能把 97 个case全列出来。硬要用 switch 也不是不行比如switch (year % 400)但那个写法又长又难维护纯粹是为了用而用。我在课上会明确告诉学生选择结构有多个工具工具选错比语法写错更费时间。2.5 查表法与位图法把400年周期压进50个字节如果你已经过了入门阶段可以看看这个稍微进阶的思路。闰年状态在 400 年周期内是完全重复的也就是说我们只需要把 400 个布尔值存成一张表之后任何年份只要做一次year % 400然后查表就能得到结果完全不需要每次做三次取模。用位图来存更省空间400 个比特等于 50 个字节。每一年对应一位闰年为 1平年为 0。查询逻辑大概是/* 概念示意把 400 年周期压成位图按位查询 */ static const unsigned char leap_bitmap[50] { /* 预先算好的数据 */ }; int is_leap_by_bitmap(int year) { int idx ((year % 400) 400) % 400; /* 归一化到 0..399 */ return (leap_bitmap[idx 3] (idx 7)) 1; }这个写法的好处是查询成本固定且不依赖三条规则的记忆。坏处是可读性差、数据表需要另写程序生成、还引入了魔法数字50。所以它更适合放在那种对性能极度敏感、且调用量巨大的场景里。对一道课堂练习来说理解规则可以被预计算成表这个思路比真的写出这段代码更有价值。3. 输入这一关年份从哪来、合法范围怎么定3.1 scanf 的返回值不是可有可无的装饰课本上大量出现scanf(%d, year);这种写法然后紧接着就开始判断。问题在于如果用户输入的是字母而不是数字year这个变量根本没被赋值它的内容是栈上残留的垃圾值后面所有判断都建立在垃圾数据上输出结果自然不可信。正确做法是检查scanf的返回值int year; if (scanf(%d, year) ! 1) { printf(输入不是合法整数请重新运行并输入数字。\n); return 1; }scanf返回的是成功赋值的项数这里我们只读一个整数所以期望返回 1。这个检查只多两行代码却能把一整类输入型崩溃挡在门外。我在代码评审时看到没检查返回值的scanf基本都会标出来因为它在真实项目里造成的故障非常难查——程序不崩只是结果是错的。3.2 负数年份和0年被教材悄悄跳过的边界教材上的闰年例题几乎从不提负数年份但 C 语言的取模运算对负数是有效的。-4 % 4的结果是 0-100 % 100是 0-400 % 400也是 0。这意味着在一个没有做输入校验的程序里-400会被判定为闰年。这在数学上有意义吗如果按天文纪年法公元 0 年存在且是闰年如果按常见的公元纪年习惯则没有 0 年。这个分歧不是程序能替你解决的它属于业务规则。我的做法是明确加一条范围校验把年份限制在合理区间内if (year 1 || year 9999) { printf(年份超出支持范围1-9999。\n); return 1; }把范围写死在代码里看起来很土但它把程序支持什么这件事说清楚了。另一种常见做法是允许负数但在取模前先做归一化int y ((year % 400) 400) % 400;把任意整数映射到 0 到 399 之间这样负数输入也能得到自洽的结果。两种方案都没错关键是你要知道自己选的是哪一种而不是让负数悄悄溜进来。3.3 一份可以直接抄走的测试用例矩阵写完代码之后最忌讳的就是只试一个 2024 或者 2023 就宣布跑通了。闰年判断的分支其实只有四条路径一份覆盖完整的测试用例不需要很多但每条路径都得有人走。下面这张表是我平时固定用的输入年份期望结果命中的规则分支2023平年不能被 4 整除第一关淘汰2024闰年能被 4 整除且非整百年1900平年是整百年不能被 400 整除2000闰年是整百年且能被 400 整除4闰年最小正闰年边界值1平年最小值不能被 4 整除0取决于业务定义需先确定是否支持公元 0 年-400取决于业务定义负数取模的边界2100平年未来整百年防止只看 2000的片面验证这张表里我特意把 1900 和 2000 放在一起因为它们看起来都是整百年结果却相反。只测 2000 不测 1900恰恰会漏掉百年不闰这条最容易写错的分支。这个组合测试的思路比我念十遍规则都有用。4. 代码看着对、结果却是错四个高频踩坑现场4.1 赋值与相等编译器警告为什么总被无视是赋值是判断相等这个区别人人都知道但每年还是有人写出if (year % 4 0)。有意思的是这段代码在大多数编译器上会直接报错因为赋值表达式的左值不能是常量。真正危险的是另一种形式if (flag 1)。这里flag是个变量赋值是合法的表达式的值是 1真于是这个条件永远成立。更坑的是if (flag 0)条件永远是假。现代编译器对这类写法一般会给-Wparentheses之类的警告但很多人看都不看就跳过了。我的建议是把警告级别开高并且把警告当错误处理。用 GCC 或 Clang 编译时加上-Wall -Wextra再配合-Werror让编译器替你把这类问题拦下来。写完代码不读警告等于把免费的代码审查丢掉了。4.2 运算符优先级不写括号就是给自己埋雷第一节能正常工作的一行表达式year % 4 0 year % 100 ! 0 || year % 400 0靠的是默认优先级。下面这张表列的是这段代码里用到的运算符从高到低优先级运算符结合方向在闰年判断里的作用2!右结合可用来取反判断3* / %左结合取模运算最先算6 左结合比较大小7 !左结合判断相等与不等11左结合逻辑与12||左结合逻辑或14?:右结合三目运算15右结合赋值优先级最低依赖这张表去心算优先级短表达式还能应付一旦表达式变成五六个条件混在一起出错概率就上去了。我的经验是只要一个条件里出现了两种以上不同优先级的运算符就加括号。括号不影响性能编译器照样优化但它把你以为的分组和编译器实际的分组强行统一了。另外还有一个容易忽略的点year % 4 ! 0 year % 100 0和year % 4 ! 0 || year % 100 0差一个符号结果天差地别写的时候最好把子条件都括起来。4.3 输入缓冲区残留导致的循环自己跑起来这个坑在闰年判断的变体练习里特别常见。老师常说写一个程序连续判断用户输入的年份直到输入 0 为止于是大家写出这样的循环int year; while (1) { printf(请输入年份0 退出); if (scanf(%d, year) ! 1) { printf(输入无效请重新输入。\n); continue; /* 危险非法字符仍然留在缓冲区里 */ } if (year 0) break; printf(%d 是%s\n, year, is_leap_year(year) ? 闰年 : 平年); }问题出在continue那一行。当用户输入的是字母时scanf无法把它转换成一个整数也不会把它从输入缓冲区里拿走于是这些字符一直留在那儿。下一轮循环再次调用scanf读到的还是同一批字符再次失败。表现就是程序疯狂打印输入无效停不下来CPU 占用拉满。解决办法有两个一是清掉缓冲区里的残留字符二是干脆不用scanf改用fgets读一整行再自己解析。第二种更稳我倾向于在需要循环读入的场景里统一使用fgets配合sscanf把输入和解析拆成两步出问题时也好定位。4.4 把判断写成函数后返回值又出了问题为了让代码复用大家会把闰年判断抽成函数。这时候一个新坑出现了函数声明了int返回值但某个分支忘了return函数会返回一个不确定的值。下面这段就是典型int is_leap_year(int year) { if (year % 4 ! 0) { return 0; } else if (year % 100 ! 0) { return 1; } else if (year % 400 0) { return 1; } /* 漏了最后的 return 0编译可能只给个警告 */ }在大部分编译器上走不到任何 return 的路径时返回值是未定义的可能是 0 也可能是别的。更麻烦的是如果返回值被当作布尔用恰好返回非零值程序就看起来跑对了。所以我在写这类函数时有个硬性习惯函数末尾一定还有一个兜底的 return哪怕前面的分支看起来已经穷尽了所有情况。加一行return 0;比事后花两小时定位输入输出对不上要划算得多。5. 把一次练习变成能复用的代码从判断到日历5.1 封装 is_leap_year命名、返回值与头文件把闰年逻辑收进一个函数是这道练习最有价值的升级。我通常写成这样放在一个单独的源文件里/* leap.h */ #ifndef LEAP_H #define LEAP_H /* 判断给定年份是否为闰年。 * 参数 year 应当位于 1 到 9999 之间。 * 返回 1 表示闰年0 表示平年。 */ int is_leap_year(int year); #endif/* leap.c */ #include leap.h int is_leap_year(int year) { if (year 1 || year 9999) { return 0; } return (year % 4 0 year % 100 ! 0) || (year % 400 0); }这里有几个我坚持的细节。函数名用动词或判断式短语让人一眼看出它回答的是一个是不是的问题。返回类型选int而不是含糊的布尔因为 C 语言早期版本没有stdbool.h用int在各类编译器上兼容性最好如果你用的是 C99 及以上也可以#include stdbool.h然后返回bool可读性会更好。头部注释里写明参数范围是把这个函数的责任边界文档化——范围外的输入返回 0 平年是一种明确的约定而不是未定义行为。提示函数里先写边界检查再写主逻辑这个顺序在小函数里看不出好处但当你以后维护几百行的函数时早返回能让主干逻辑始终保持在缩进最浅的一层读起来轻松很多。5.2 顺手做两个小工具二月天数与区间统计光判断一个年份是闰年感觉没什么用。加上两个衍生功能这段代码立刻就变成有用的小工具了。第一个是求某年二月的天数int days_in_february(int year) { return is_leap_year(year) ? 29 : 28; }第二个是统计某个区间内一共有多少个闰年。这里有两种实现思路一种是简单循环逐年调用判断代码三行搞定另一种是利用 400 年周期性用公式直接算速度更快但可读性差。我一般先写循环版因为区间不大的时候它完全够快而且不存在出错风险int count_leap_years(int from, int to) { int count 0; for (int y from; y to; y) { if (is_leap_year(y)) { count; } } return count; }有了这两个函数你就能很自然地扩展出一个打印某年日历的小程序先确定该年 1 月 1 日是星期几然后逐月打印遇到二月就调用days_in_february拿到 28 或 29。整个过程里闰年判断这个小题变成了日历程序的地基学生也就能看到为什么要把判断抽成函数——因为它会被反复调用而且调用它的地方不关心它内部是怎么判断的。5.3 用断言和打印中间值把验证变成习惯最后说一个我觉得比任何技巧都重要的习惯不要靠肉眼看输出猜对错要让程序自己告诉你哪里不对。最轻量的做法是用断言#include assert.h int main(void) { assert(is_leap_year(2024) 1); assert(is_leap_year(2023) 0); assert(is_leap_year(1900) 0); assert(is_leap_year(2000) 1); assert(is_leap_year(4) 1); printf(全部断言通过\n); return 0; }把第 3 节那张测试用例表直接翻译成断言跑一次就知道有没有踩到逻辑错误。注意assert在定义NDEBUG宏之后会被整个编译掉所以正式发布时这些检查不会带来任何性能负担这让它非常适合用来做我自己确认过的这类自检。另一个习惯是当结果不符合预期时先把中间值打出来。比如判断出错时不要盯着最终输出发呆直接在判断前加上printf(debug: year%d, mod4%d, mod100%d, mod400%d\n, year, year % 4, year % 100, year % 400);三次取模的结果一看问题往往立刻暴露——比如mod4是 0、mod100也是 0、mod400不是 0那这个年份就应该被判为平年如果程序输出闰年说明你的条件组合写反了。这个办法土但在没有调试器或者不方便打断点的时候它解决问题最快。再说一个我自己的感受闰年判断这类题目新手容易犯的错误不是不会写而是写完就跑一个样例然后收工。真正拉开差距的地方在于你有没有把那几个整百年、边界值、非法输入都拿出来试一遍有没有在写完逻辑表达式之后再检查一遍括号和运算符。这些动作本身不产生新代码但它们决定了你是写出了能跑的代码还是写出了能用的代码。我自己带过的实践里凡是主动把测试用例补齐的同学后面学数组、指针、文件操作时出的低级错误明显少一截因为验证这件事已经变成了条件反射。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑