【题目描述】
输入年份,判断是否为闰年。如果是,则输出yes,否则输出no。
提示:简单地判断除以4的余数是不够的。
【题目来源】
刘汝佳《算法竞赛入门经典 第2版》习题1-7 年份(year)
【解析】
一、闰年的由来及设定规则
首先要明白,设置闰年的目的是为了弥补历法规定的年度天数与地球实际公转周期(一个回归年)的时间差。因为规定平年365天,而实际的一个回归年大约比平年多出0.2422天。为了补偿这个差异,使历法年与回归年相适应,产生了闰年的概念。
在现行公历中,闰年的设定遵循以下规则:
①能被4整除但不能被100整除的年份为普通闰年。
②能被400整除的年份为世纪闰年。
为什么是这么个复杂规则呢?
前面说过,一个回归年大约比平年多出0.2422天,4年就多0.2422×4=0.9688天,而历法是每4年补1天,相当于每4年多补了1-0.9688=0.0312天。
短期内这点误差没什么影响,可时间长了误差会越来越大,每400百年就会多被3.12天。所以为了减小误差,在每4年一个闰年的基础上,每400年还要减少3个闰年。
减少哪3个闰年呢?历法制定者一拍脑门,就设在世纪之交吧,在第100、200、300年各减少1个。
这个拍脑门动作直接将世纪闰年变成稀有产品,比如2000年是世纪闰年,这意味着其后300年内出生的小朋友都与世纪闰年无缘了。
以上就是“四年一闰、百年不闰、四百年又闰”的规则的由来。
二、判定闰年的程序
需把闰年的判定规则转化为对应的表达式:
规则①中“能被4整除但不能被100整除”表示这两个条件需同时成立,这是“逻辑与”的关系,用&&表示,即:year % 4 == 0 && year % 100 != 0。
规则②的表达式为:year % 400 == 0
满足规则①与②任意一项就是闰年,所二者是“逻辑或”的关系,用||表示,即:①||②。
c代码:
#include <stdio.h>int main() {int year;scanf("%d", &year);if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {printf("yes\n");} else {printf("no\n");}return 0;}
上述代码中用于判断的表达式① year % 4 == 0 && year % 100 != 0的括号其实是没有必要的,因为&&的优先级是高于||的。但是,为了代码的清晰性和可读性,加上括号是一个比较好的习惯——尤其在涉及复杂逻辑运算时。
好了,这道题就讲完了。
我估计解这道题的多数同学都只会给出上面的代码,认为它就是正解。
可能连出题人也都这样认为。
因为算法课上老师都是这么讲的,所以我们都容易依惯性思维给出上面的答案。
然而,这道题真的解完了吗?
NO,NO,NO,这里面还有一个大坑。
认为上述代码是正解的都忽略了一个问题。
那就是:公元前的年份。
在一般的算法竞赛的题目中,都会给出年份的范围,比如1900 <= year <= 2500。但是本题,并没有给出范围,所以公元前的年份无疑也是符合题目要求的。
问题来了,公元前的闰年与公元后的闰年判定规则是一样的吗?
如果你在网上搜索一下,公元前闰年的判定规则,会发现有如下一些表述:
这些描述都有一些共同的特点:
①表现得神乎其神,却让人看得云里雾里;
②满满的漏洞;
③将简单的问题复杂化。
公元前闰年的判定本就是很简单的问题。
只需一句话:把公元前的年份减1,然后按公元后的规则判定即可。
比如,公元前2001年,减1等于2000年,按公元后的判定规则判定为闰年。
不知为啥,网上居然没见到一个以这种方式描述的。
为什么公元前的闰年规则和公元后不一样呢?这是个数学问题。
因为公历没有公元0年,公元1年的前一年就是公元前1年,而闰年的基本规则是每4年一个闰年,所以公元前1年就变成了闰年。
前6 | 前5 | 前4 | 前3 | 前2 | 前1 | 1 | 2 | 3 | 4 | 5 | 6 |
闰年 | 闰年 | 闰 |
换句话说,如果有公元0年的话,那公元前和公元后的闰年规则就是一样的了。
正因为差了这一年,导致了公元前后闰年判定的差异。
现在你明白了刚刚说的公元前要减1再判断的原因了吧?
为简化起见,咱们在编程时假定用负数表示公元前的年份,这样的话就不再是减1后判断,而是要加1了。比如公元前1年,表示为“-1”,要加1才能变成0。
原来的代码改起来很简单,只要在判断之前加一个if语句即可:
if(year<0) year+=1;
以上就是本题的答案部分。
三、逻辑表达式的效率判定
下面再讨论一下由本题衍生出来的一个问题:逻辑表达式的效率问题。
从效率角度讲,下面的表达式A与B、C与D是一样的吗?
表达式A:year % 4 == 0 && year % 100 != 0
表达式B:year % 100 != 0 && year % 4 == 0
表达式C:(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
表达式D:year % 400 == 0 ||(year % 4 == 0 && year % 100 != 0)
这涉及到&&、||这两个运算符的一个运算特点:短路(short-circuit)效应。
这个短路效应其实很好理解:
①如果a为假,则b无论真假,a&&b均为假,所以就不再计算b的值。
②如果a为真,则b无论真假,a||b均为真,所以就不再计算b的值。
表达式year % 4 == 0为假指年份不能被4整除,表达式year % 100 != 0为假指年份能被100整除,从输入年份的概率角度讲,显然前者远远大于后者,所以把前者放在&&之前,就能减小计算次数,故表达式A的效率高于表达式B。
表达式year % 4 == 0 && year % 100 != 0为真指年份能被4整除但不能被100整除,表达式year % 400 == 0为真指年份能被400整除,显然前者概率远远大于后者,把前者放在||之前,也能减小计算次数,故表达式C的效率高于表达式D。
也就是说,咱们代码中逻辑表达式的排序(即表达式C)就是效率最高的。
而效率最低的是下面这个表达式:
表达式E:year % 400 == 0 ||( year % 100 != 0 && year % 4 == 0)
从概率的角度说可能不易理解,如果把这道题改成依次输出公元前3000年到公元3000年每一年是否是闰年,大家就能立刻明白逻辑表达式不同的排序计算次数会有很大的不同了。
但是能不能输出具体的计算次数对比呢?
我家孩子提供了一个思路,逻辑判断可以用if-else语句替换,通过这种替换就能直接让程序输出不同排序的计算次数。
以下是孩子编的C++代码,我没更改,只是把变量名改得更清晰些(比如将bool变量名由s改为is_true,将循环变量i改为year),加了些注释。
表达式C的计算次数代码如下:
#include <iostream>using namespace std;int main () {bool is_true; //判断表达式真假//sum4是i%4==0的计算次数//sum100是i%100!=0的计算次数//sum400是i%400==0的计算次数int sum4=0,sum100=0,sum400=0,sum;for(int year=-3000;year<=3000;year++){if(year==0) year++; //跳过0年//公元前的年份+1int year1=year;if(year1<0) year1=year+1;//获得第1、2个表达式的计算次数sum4++;if(year1%4==0){sum100++;if(year1%100!=0)is_true=true;elseis_true=false;}else{is_true=false;}//获得第3个表达式的计算次数if(is_true==false) {sum400++;if(year1%400==0)is_true=true;elseis_true=false;}//输出年份是否为闰年if(is_true==true)cout<<year<<" yes"<<endl;elsecout<<year<<" no"<<endl;}sum=sum4+sum100+sum400;cout<<sum4<<" "<<sum100<<" "<<sum400<<" "<<sum;return 0;}
表达式E的计算次数代码如下:
#include <iostream>using namespace std;int main () {bool is_true;int sum4=0,sum100=0,sum400=0,sum;for(int year=-3000;year<=3000;year++){if(year==0) year++; //跳过0年//公元前的年份+1int year1=year;if(year1<0) year1=year+1;//获得第1个表达式的计算次数sum400++;if(year1%400==0)is_true=true;elseis_true=false;//获得第2、3个表达式的计算次数if(is_true==false){sum100++;if(year1%100!=0){sum4++;if(year1%4==0)is_true=true;}}//输出年份是否为闰年if(is_true==true)cout<<year<<" yes"<<endl;elsecout<<year<<" no"<<endl;}sum=sum4+sum100+sum400;cout<<sum4<<" "<<sum100<<" "<<sum400<<" "<<sum;}
程序输出的两种表达式的计算次数如下:
表达式 | year % 4 == 0 | year % 100 != 0 | year % 400 == 0 | 合计 |
表达式C | 6000 | 1500 | 4560 | 12060 |
表达式E | 5940 | 5985 | 6000 | 17925 |
E-C | -60 | 4485 | 1440 | 5865 |
上面的代码逻辑有一点点复杂,但仔细看还是能看明白的。有一点比较有意思的是,这个程序还有一个小坑,就是“公元0年”是没有的,要注意刨除掉。我本来想顺道考查下孩子会不会用continue,这小子果然不会,但人家也不含糊,想到用year++的这种方式跳过了0。