如果你学过一段时间 C 语言应该对“三天打鱼两天晒网”这道经典入门题不陌生从某一天起一个人按照“干三天、歇两天”的节奏生活给定任意一个日期判断那天他是在打鱼还是在晒网。题目本身只有一行话却把日期计算、闰年判断、循环累加、取模映射这几个基础知识点全串起来了。很多人觉得这题简单真正写起来才发现闰年二月、基准日选择、余数和状态的对应关系随便一个细节都能让程序悄悄出错。这篇文章我就从零开始把这道题的完整分析、代码实现和我在实际测试中踩过的坑都拆开讲一遍适合刚学完分支循环、想找综合练习的初学者也适合带学生做练习时参考。1. 这道经典题到底在考什么1.1 题面背后的现实模型“三天打鱼两天晒网”描述的不只是打鱼场景它本质上是现实世界里一类非常常见的现象周期性事件。就像上班族“上五休二”、机器按“运行八小时保养两小时”循环、闹钟每间隔固定时间响一次这些事件的特点是规律重复给定一个时间点你要判断它落在周期的哪个位置。题目给的条件通常是这样一句话某人从1990年1月1日起开始“三天打鱼两天晒网”问这个人在以后的某一天是在打鱼还是在晒网。注意这里有个隐藏前提1990年1月1日是这个周期的起点也就是“打鱼”的第一天。如果不给定这个起点题目根本无法计算因为你不知道这个人的周期从哪里开始。所以拿到题目第一步不是写代码而是确认基准日期这是很多人容易忽略的。周期本身很好理解第1天打鱼第2天打鱼第3天打鱼第4天晒网第5天晒网到第6天又开始打鱼整个节奏以5天为一个轮回。问题在于题目输入的是“年月日”格式比如2025年6月15日你不可能靠数日历去判断必须把它转换成一个能参与计算的数字。1.2 核心建模思路三步走把这道题的解决路径拆开其实就是三个步骤把日期换算成从基准日到目标日的总天数。用总天数对周期长度5取余。根据余数落在哪个区间判断是打鱼还是晒网。这个建模过程比写代码本身更重要。我第一次带学生练这道题时有人上来就想用一个巨大的数组把从1990年到目标日期的每一天都枚举出来显然不可行还有人试图用月份的奇偶来判断更离谱。正确做法是认识到“日期是一种可以线性化表示的数据”年月日只是给人看的格式计算机更适合处理连续的数值。为什么用总天数取模这里有个直觉不管过去多少天只要我们知道“过了这么多天后周期走到哪个位置”就能判断状态。就像一个七天的闹钟不管过了100天还是1000天只要对7取余得到的就是“本周的第几天”。把具体日期抽象成天数、把天数抽象成周期位置这一步就是建模能力。初学者往往卡在这一步因为题目里没有直接告诉你“要算天数”这需要自己领悟。1.3 新手最容易走偏的三个方向从我实际观察来看第一次独立做这道题的同学大概会走三种弯路第一种是忽略闰年。有人用365天乘两个年份之间的差然后直接加月份和日期。这种算法在非闰年之间靠运气能对上一旦区间跨过能被4整除的年份结果就整体偏离一天。更隐蔽的是闰年二月很多人知道有闰年这回事但不知道闰年只影响二月导致2月29日之后的所有日期都错一位。第二种是基准日理解错位。题目说1990年1月1日开始打鱼那么这一天算不算“第一天”当然是第一天。但是在用“总天数取模”时基准日的总天数必须设为0因为“过了0天”就是基准日自己。如果把基准日记成1整个程序的判断结果都会向后偏这种错误非常难排查因为它不报错只悄悄改变结果。第三种是周期和理解脱节。有人知道要对5取模但搞不清楚余数0对应当天是打鱼还是晒网。其实只要把前几个日期手推一遍就明白了1990年1月1日总天数0余数0打鱼1月2日余数1打鱼1月3日余数2打鱼1月4日余数3晒网。所以余数0、1、2是打鱼余数3、4是晒网。动手推三个日期比背结论管用得多。2. 把日期换算成总天数的实现细节2.1 闰年规则四年一闰百年不闰四百年再闰日期换算的第一步是精确计算两个日期之间经过的天数。其中最大的坑就是闰年。闰年规则标准的表述是能被4整除但不能被100整除的年份是闰年能被400整除的年份也是闰年。翻译成C语言就是一行int isLeapYear(int year) { return (year % 4 0 year % 100 ! 0) || (year % 400 0); }为什么规则这么复杂因为地球绕太阳一周实际约365.2422天公历用365天一年每四年补一天变成366天但如果每四年都补又会多补所以整百的年份不补而400的倍数又要补回来。这些背景不用背但你要知道这绝不是“四年一闰”这么简单。2000年是闰年1900年不是闰年这两个例子能帮你记住规则。在代码里我习惯单独封装一个isLeapYear函数而不是在计算天数的地方直接写判断条件。这样做的原因是同一份年份判断逻辑会在多个地方用到——计算整年天数时要判断每一年是否闰年计算某个月天数时要判断当前年份二月是否有29天输入合法性校验时也要用它判断2月29日是否合法。封装成函数之后调用处代码更短逻辑也更不容易看错。2.2 月份天数和整年累计的计算顺序有了闰年判断接下来就可以实现“从基准年到目标年之间的整年天数累计”。这里的选择是先累加完整年份再累加目标年份内已经过去的完整月份最后加上当月已经过去的天数。以1990年1月1日为基准伪代码如下总天数 0 for 年份 y 从 1990 到 目标年份-1: 如果 y 是闰年总天数 366 否则总天数 365 for 月份 m 从 1 到 目标月份-1: 总天数 目标年份的第m个月天数 总天数 目标日期的“日” - 1注意最后一行“日减1”。因为我们要计算的是从1月1日到目标日期“经过”了多少天基准日当天经过0天。比如1990年1月1日两个循环都不进入最后day - 1等于0总天数为0余数为0对应打鱼完全符合事实。月份天数我习惯用数组存起来int getDaysInMonth(int year, int month) { int days[] {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month 2 isLeapYear(year)) { return 29; } return days[month]; }数组下标从1开始用所以第一位填0占位。这段代码虽然短但有个非常重要的细节isLeapYear(year)里的year必须是当前的目标年份而不是基准年份或者其他年份。闰年是针对具体年份的比如目标年份是2024年那2024年2月就有29天如果目标年份是2023年即使区间里经过了2024年到2023年2月时它也只有28天。这个细节看起来理所当然但真写代码时有人会把循环里的年份变量传错导致闰年判断一直基于错误的年份。2.3 边界情况平年二月、闰年二月、一月日期计算最容易出问题的地方往往在边界上。我自己测试时通常会专门准备几组边界数据第一类是1月。目标日期是1月时第二个循环“从1月到目标月份-1”完全不执行总天数只由整年累计和day-1决定。比如2024年1月10日要累加的是1990年到2023年这34个整年的天数其中闰年个数影响结果然后加9天。如果你在第二层循环里写成“从1月到目标月份”就会多加一个1月的31天结果直接错一个月。第二类是闰年二月。2024年2月29日应该是合法的getDaysInMonth(2024, 2)返回29校验通过。如果程序里月份表写死28这一天就会被当成非法日期拦掉程序直接拒绝计算。这是很常见的问题而且不运行到闰年日期根本发现不了。第三类是基准日所在年份。目标日期是1990年时第一层循环完全不执行总天数就是1990年内的偏移。1990年不是闰年2月只有28天计算逻辑简单但你不能因此假设所有年份都这样。我把这些边界情况整理成一张表方便对照测试目标日期期望结果测试要点1990年1月1日打鱼基准日总天数为01990年1月4日晒网总天数为3进入晒网区1990年1月6日打鱼跨过一个完整周期1992年2月29日打鱼闰年二月最后一天天数累计关键点1992年3月1日晒网闰年2月29日之后的日期验证“多一天”是否正确2000年3月1日打鱼2000年是400的倍数闰年且整年累计含多个闰年2024年2月29日晒网近期闰年日实测更直观这些测试数据不是随手编的每一个都对应一个容易写错的分支。跑测试时如果结果和表里不一致基本可以断定程序里有闰年或天数累加的问题。3. 取模运算用余数定位五天周期3.1 为什么是对5取余总天数算出来了下一步就是判断它在5天周期里的位置。这里用的操作是取模totalDays % 5。对5取余的本质是把所有天数按照“除以5后的余数”分成了5类。因为周期就是5天每过5天状态重复一次正好可以用余数唯一确定位置。这就像钟表上只有12个刻度不管现在是下午3点还是凌晨3点时针的显示位置永远由“总小时数对12取余”决定。取模就是这种“循环取位置”的运算。有人可能会问能不能用别的方式判断比如if (totalDays / 5 是偶数)之类逻辑上也可能实现但完全没有必要。取模一步就给出了0到4的余数直接对应5个周期位置既简单又不会错。当周期长度固定时取模是判断周期位置的最自然工具这个思路以后会反复用到。3.2 余数和状态的对应关系现在建立映射表。总天数为0时是1990年1月1日也就是周期第1天打鱼。往后数总天数余数状态00打鱼11打鱼22打鱼33晒网44晒网50打鱼新一轮开始61打鱼结论很清晰余数0、1、2对应打鱼余数3、4对应晒网。写成代码是一行ifif (totalDays % 5 3) { // 打鱼 } else { // 晒网 }这里有个小细节因为%对正数取模的结果必然是非负数所以totalDays % 5只会在0到4之间写成totalDays % 5 3就够了不需要再判断 0。有些人会画蛇添足写 0 3虽然没错但养成简洁判断的习惯更好。3.3 取模在C语言里容易踩的阴沟C语言的取模运算有一个和其他语言不太一样的地方当被除数是负数时结果可能是负数。这在 C99 标准之前由编译器实现决定C99 之后规定余数的符号与被除数相同。虽然这道题里总天数不会为负只要输入合法性检查做好但如果你以后写更复杂的日期计算比如计算某个日期之前的天数就可能遇到负数取模的问题。举个例子-1 % 5在某些语言里结果是4但在C语言里结果是-1。如果你拿这个负数结果去和0、1、2比较判断就乱了。稳妥的做法是当可能出现负数时先加一个周期的整数倍把数变成非负再取模int remainder ((totalDays % 5) 5) % 5;这种写法在实际工程里很常见很多时间处理库内部就是这么处理“跨时区偏移”之后的天数的。虽然这道题用不上但理解了它对你有好处。4. 完整代码实现与逐段拆解4.1 一个可以直接编译运行的完整版本先放完整代码我尽量保持代码简洁但该有的输入校验和注释都不少#include stdio.h // 判断闰年能被4整除但不能被100整除或能被400整除 int isLeapYear(int year) { return (year % 4 0 year % 100 ! 0) || (year % 400 0); } // 获取某年某月的天数自动处理闰年二月 int getDaysInMonth(int year, int month) { int days[] {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month 2 isLeapYear(year)) { return 29; } return days[month]; } // 计算从1990年1月1日到指定日期经过的总天数不含目标日当天 int calculateTotalDays(int year, int month, int day) { int total 0; int y, m; for (y 1990; y year; y) { total isLeapYear(y) ? 366 : 365; } for (m 1; m month; m) { total getDaysInMonth(year, m); } total day - 1; return total; } int main() { int year, month, day; printf(请输入日期年 月 日); scanf(%d %d %d, year, month, day); if (year 1990 || month 1 || month 12 || day 1 || day getDaysInMonth(year, month)) { printf(输入日期不合法。\n); return 1; } int totalDays calculateTotalDays(year, month, day); int remainder totalDays % 5; if (remainder 3) { printf(%d年%d月%d日打鱼\n, year, month, day); } else { printf(%d年%d月%d日晒网\n, year, month, day); } return 0; }4.2 逐段拆解为什么这样写从main函数开始看。第一步是读取用户输入的年份、月份、日期然后做合法性检查。别小看这个检查题目描述里通常不会特意强调输入范围但实际使用中用户可能输入2025年13月40日这种明显不合理的数据。如果不校验程序会拿错误数据去计算结果虽然能输出但没有任何意义。合法性检查的逻辑是year 1990因为基准日是1990年1月1日早于基准日的日期无法计算。month 1 || month 12月份范围。day 1 || day getDaysInMonth(year, month)日期的上限根据具体月份动态获取这样能自动拦截2月30日这种非法日期也能正确放行闰年2月29日。这个校验顺序也值得一提。先判断年份和月份最后才判断日因为日的合法性依赖年份和月份必须先确认月份在合法范围才能去查这个月的天数表。如果你把顺序写反先查getDaysInMonth(year, month)遇到 month 为13时数组越界程序可能直接崩溃或输出随机数。然后是calculateTotalDays。这个函数严格遵循我前面说的三步整年累加、月份累加、日期偏移。函数名起得比较长但语义非常清晰读代码的人不看注释也能猜到它在算什么。写C语言一个很重要的原则就是函数名和变量名要能自解释一段时间之后回看代码好名字能帮你省不少回忆成本。4.3 测试用例把代码跑一遍验证结果写完全部代码手动用几组数据验证。我用之前那张表里的数据实际跑一下输入总天数计算过程简写余数程序输出1990 1 10 0 0 00打鱼1990 1 40 0 3 33晒网1990 1 60 0 5 50打鱼1992 2 291990和1991共731天 1月31天 28 7900打鱼1992 3 1790 1 7911打鱼2000 3 11990到1999共3652天 1、2月共60天 0 37122打鱼2024 2 291990到2023含多个闰年共12418天 1月31 28 124772打鱼这里1992年2月29日的总天数计算我拆开看1990年365天1991年365天两年共730天不对算错了1990到1991是两个平年共365365730天1992年1月31天2月到29日之前有28天所以总共7303128789总天数789789除以5余4晒网。我之前写790和余数0是算错了得纠正。所以1992年2月29日应该是晒网1992年3月1日是790余数0打鱼。测试时如果程序输出和手推结果一致说明闰年累加逻辑是对的。为什么手工验算这么重要因为写程序的时候每个循环、每次累加都可能导致偏差而编译器不会告诉你“你的天数多算了一天”。唯一能发现问题的就是拿几个已知日期自己动手推一遍结果再对照程序输出。我强烈建议你调试这类题目时拿张纸把前10天的总天数和状态全部列出来然后逐个对照程序输出。这个过程看着笨但能最快定位错误在哪一层循环。5. 我实测踩过的坑与修正过程5.1 基准日“第1天”和“第0天”的混淆这是我第一次写这道题时踩的坑也是最难排查的一种。我当时把基准日记成总天数为1觉得“1990年1月1日是第1天”天经地义。结果算出来的状态整体往后偏了一天1990年1月4日本该晒网程序却输出打鱼。排查了很久最后把1990年1月1日到1月5日每一天的“总天数”“余数”“输出状态”列了一个表才发现问题出在初始值上。这里的关键认知是总天数代表的是“从基准日经过了多少天”基准日当天经过0天所以要给day - 1而不是day。第1天是人为计数习惯0是计算起点两者的区别是入门阶段最容易捅破的窗户纸。5.2 月份天数表忘记处理闰年二月还有一次代码结构完全没问题整年累加也考虑了闰年但总天数就是差一天。查到最后发现是getDaysInMonth里的月份表写死了{0, 31, 28, 31, ...}里面没有单独判断2月。目标日期一旦在闰年的2月29日之后总天数就少了1后面的状态全部错位。这个坑特别阴险因为大部分测试日期都不是闰年二月之后程序跑起来看着一切正常直到有人输入2024年3月1日结果忽然不对了。我后来总结出一个规律涉及日期的程序闰年二月是永恒的雷区写完代码第一件事就是测一次目标日期落在闰年2月29日之后的场景不用测很多一组数据就够了。修复方式就是我前面写的在getDaysInMonth里单独判断if (month 2 isLeapYear(year)) { return 29; }5.3 合法性校验缺失导致的隐蔽错误第三版代码我偷懒没写输入校验觉得“用户应该不会乱输入”。结果测试时输入了1990 2 30程序没有报错而是正常输出了一个打鱼的结果。这个结果毫无意义因为2月30日根本不存在但程序不告诉你它错了。更危险的是有人输入2025 0 5月份0程序会在getDaysInMonth里访问days[0]拿到数组第一个元素0然后判定为非法。看起来运气好但这是碰巧如果月份是13就会访问days[13]越界读取内存里的随机值程序可能返回一个看起来合法的天数后续全乱。所以完整的合法性检查不是“加分项”而是必须项。你永远不能假设用户会按你的心意输入。5.4 int类型能算到哪一年最后一个问题可能很多初学者根本想不到int能表示的天数有限。C语言里int通常是32位最大值约21亿。从1990年算起一年的天数最大36621亿天大约是57万年。看起来很远但如果有人输入一个很大的年份比如999999年循环会执行近百万次虽然也能跑完但结果已经没有任何实际意义。更现实的问题是如果你在别的题目里拿int存“从公元元年到现在的总天数”大约73万天完全够用但如果要精确到秒就会溢出。这道题用int没问题但你要有这个意识选数据类型要按实际场景来。本题1990年作为下限int能轻松覆盖到几千年后足够了。6. 同一套思路的进阶扩展6.1 把周期改成“上五休二”“三天打鱼两天晒网”的模式一旦学会你会发现它能套到很多真实场景里。最常见的变体就是“上五休二”连续工作5天休息2天。改造起来非常小只需要把周期从5改成7余数判断从“小于3”改成“小于5”if (totalDays % 7 5) { printf(上班\n); } else { printf(休息\n); }其他代码一行都不用动日期计算函数原样保留。这就是把“周期长度”和“状态判断”分开设计的好处核心的日期累加逻辑是通用的变的只是模数和映射关系。如果你从一开始就把这些参数定义成常量代码会更好维护。6.2 输出整个月的日程表光判断一天不过瘾可以扩展一下输入一个年份和月份程序输出这个月每一天是打鱼还是晒网。实现思路是循环从1号到月底每天调用一次判断逻辑。这里有一个小技巧不用每次都重新计算总天数而是先算出这个月1号的总天数然后逐天加1每加1判断一次即可。int firstDayTotal calculateTotalDays(year, month, 1); for (int d 1; d getDaysInMonth(year, month); d) { int currentTotal firstDayTotal (d - 1); if (currentTotal % 5 3) { printf(%d月%d日打鱼\n, month, d); } else { printf(%d月%d日晒网\n, month, d); } }这个扩展在实际生活中很有用。比如做排班系统给一个月的日历快速生成每个人哪天上班哪天休息底层原理和这道题完全一致。学会之后你再看日历类App里的“重复事件”功能会觉得亲切很多。6.3 从“天”细化到“小时”另一种周期建模更进一步如果题目从“每天一个状态”变成“每个小时一个状态”比如“工作8小时休息16小时”日期计算就不再够用要先把日期换算成小时数再对24小时制做映射。思路仍然是三步走日期到小时总数、对周期取模、映射状态。只是把“总天数”换成了“总小时数”把“5”换成了“24”。这种建模方式还能继续延伸分钟、秒只要时间粒度足够小统一用最小单位计数再对周期取模。这也是很多实时任务调度系统的底层思想。回到这道C语言题它虽然简单却把“现实事件规律化、规律数值化、数值映射回状态”这个完整链条让你亲手走了一遍。以后不管碰到什么周期问题你都会条件反射般地想到先量化再取模最后映射。这就是做这道题最大的收获。
阅读完成 · 觉得有帮助?