如何计算精确年龄
前言
一般我们说某人年龄的时候,如果指的是周岁,都是以其生日为界,也就是用要计算的年份减去出生年份,如果要计算的日期在生日之前,则减去一岁。
例如:某人2000年5月10日出生,到2001年5月1日,我们说他未满周岁,也就是0岁;而自2001年5月10日开始到下一年5月9日,则说他1周岁。用伪代码(既然是数据库相关的,就用类似SQL的伪代码,并不针对特定数据库,后面的伪代码皆同)表示就是
DECLARE @birthday DATE --生日
DECLARE @calc_day DATE --待计算的日期,假设@calc_day总是大于@birthday
DECLARE @age INT --结果,周岁年龄
DECLARE @diff_months INT --中间变量,月的差值
SET @birthday = '2000-05-10'
SET @calc_day = '2001-05-01'
SET @age = YEAR(@calc_day) - YEAR(@birthday) --先计算年份的差值。YEAR():获取指定日期年份部分的整数值。
SET @diff_months = MONTH(@calc_day) - MONTH(@birthday) --再计算月份的差值。MONTH():获取指定日期月份部分的整数值,1~12。
IF @diff_months = 0 --如果月份相同,再比较日的部分。
IF DAY(@calc_day) < DAY(@birthday) SET @diff_months -= 1 --比较日部分的大小,待算日比生日小,则把月差值减去1。DAY():获取指定日期日部分的整数值,1~31。
IF @diff_months < 0 SET @age -= 1 --如果月差值小于0,则把周岁结果减去1
PRINT @age --输出结果
但在某些时候,特别是死板的计算机世界里,有时需要计算出精确的年龄,形式为几岁几月几天(YMD格式),下面的讨论就是解决这个问题。
具体问题
给定生日和其之后的某个日期(称为待算日),计算出“年数:月数:天数(years:months:days)”形式输出的精确年龄。年数(years)的范围为>=0的整数,月数(months)的范围为 0 ~ 11 的整数,天数(days)的范围为 0 ~ 30 的整数。
如前面的例子中,2001年5月1日时,精确年龄应该是“0岁11个月22天(0:11:22)”(生日当天记为0)。到了2001年5月10日,则是“1岁0个月0天(1:0:0)”。
问题分析
乍看上去,这个问题并不复杂,有两种思路:
- 如同算精确时间差(时:分:秒)一样,先算出两个日期之间的天数,再逐级除以每月天数和每年月数分别取余数即可。
- 扩展前面算周岁的算法,分别算出日的差值、月的差值和年的差值,再根据每月天数和每年月数,用借位规则和进制处理差值的负值即可。
第一种思路再稍微考虑就会觉得不可行,月数的进制倒是固定值12,但每月的天数是不同的,而且还有闰月干扰,天数的进制在变化,到底除几才行?
第二种思路是计算周岁的扩展,并不复杂。把前面的伪代码稍加改造即可:
DECLARE @birthday DATE --生日
DECLARE @calc_day DATE --待计算的日期,假设@calc_day总是大于@birthday
DECLARE @age INT --结果中年差值的部分
DECLARE @diff_months INT --结果中月差值的部分
DECLARE @diff_days INT --结果中日差值的部分
SET @birthday = '2000-05-10'
SET @calc_day = '2001-05-01'
SET @age = YEAR(@calc_day) - YEAR(@birthday) --计算年差值
SET @diff_months = MONTH(@calc_day) - MONTH(@birthday) --计算月差值
SET @diff_days =DAY(@calc_day) - DAY(@birthday) --计算日差值
IF @diff_days < 0 --如果日差值 <0,则把月差值减去 1,并按生日所在月的天数重置日差值(借位)。
BEGIN
SET @diff_months -= 1
SET @diff_days += DAYSOFMONTH(@birthday) --DAYSOFMONTH():获取指定日期所在月份的天数。
END
IF @diff_months < 0 --如果月差值 <0,则把年差值减去 1,并把月差值 +12重置月差值(借位)。
BEGIN
SET @age -= 1
SET @diff_months += 12
END
--输出结果,FORMAT()按格式化字符串的描述,将变量格式化为字符串。格式化字符串的语法参考C#。
PRINT FORMAT(@age, '#00') + ':' + FORMAT(@diff_months, '00') + ':' + FORMAT(@diff_days, '00')
注意:在天数借位的时候,所加的是生日所在月的天数,如果用待算日天数的话,可能会出现负数,比如当生日是3月31日,待算日是2月28日时。
程序输出的结果是00:11:22,看起来没问题。但是如果真这么简单,就不值得把这件事记录下来啦。
情况一
下面基于这个算法做个测试。我们可以先把上面的程序转换成函数形式,把生日和待算日作为传入的参数,计算结果作为返回值,函数定义是这样 FUNCTION VARCHAR(20) CalcAge_01 (@birthday AS Date, @calc_day AS Date) RETURNS VARCHAR(20),函数实现就不重复写了。
然后再编写一段小程序进行测试:
DECLARE @i INT
DECLARE @birthday DATE
DECLARE @calc_day DATE
SET @i = 0
SET @birthday = '2001-04-30'
SET @calc_day = '2001-04-30'
WHILE (@i < 70) --循环多次,每次把计算日 +1
BEGIN
PRINT FORMAT(@i, '000 ') + FORMAT( @birthday, 'yyyy-MM-dd') + ' to ' + FORMAT( @calc_day, 'yyyy-MM-dd') + ' -> '+ CalcAge_01(@birthday, @calc_day) --输出序号、生日、计算日和结果
IF(DAY(@calc_day) = DAYSOFMONTH(@calc_day)) PRINT '-----------------------------------------' --新月份之前画条分割线
SET @calc_day = ADDDAY(@calc_day, 1) --ADDDAY():将参数1指定的日期,增加参数2指定的天数,返回新日期
SET @i += 1
END
输出结果如(图1)。
可以看到,前面30行都挺正常,跟预想的一样,5月1日是1天(00:00:01),5月30日是1整月(00:01:00)。事情从第31行开始变得奇怪了,5月31日是1个月零1天(00:01:01)看起来没问题,但是到了6月1日,仍然是(00:01:01),这明显出了问题。如果把生日换成2月28日,这个情况会更加明显。而这个算法也是网上搜索到的最常见的写法,似乎都没有考虑到这个情况。
接着来到第61行,6月30日,是2个月整(00:02:00),这倒是符合一般习惯,我们通常把跨越不同月份的相同日期作为整月。
情况二
我们再换个边界日期值试试。
DECLARE @i INT
DECLARE @birthday DATE
DECLARE @calc_day DATE
SET @i= 0
SET @birthday= '2001-03-31'
SET @calc_day= '2001-03-31'
WHILE (@i<70) --循环多次,每次把计算日 +1
BEGIN
PRINT FORMAT(@i, '000 ') + FORMAT( @birthday, 'yyyy-MM-dd') + ' to ' + FORMAT( @calc_day, 'yyyy-MM-dd') + ' -> '+ CalcAge_01(@birthday, @calc_day) --输出序号、生日、计算日和结果
IF(DAY(@calc_day) = DAYSOFMONTH(@calc_day)) PRINT '-----------------------------------------' --新月份之前画条分割线
SET @calc_day = ADDDAY(@calc_day, 1) --ADDDAY(),将参数1指定的日期,增加参数2指定的天数,返回新日期
SET @i += 1
END
输出结果如(图2)。
和前面一样,前面30行都没问题,但是奇怪的事情再次发生了,在第30和31行,00:00:30和00:01:01之间似乎缺了点什么。没错,少了00:01:00,也就是没有1整月。这似乎不太对。但是想想另一个例子,每位在闰年2月29日出生的人,都只能每4年过一次生日,但并不妨碍他们每年涨一岁,只不过他们在每个非闰年的2月底(2月28日),精确年龄是n:11:28(差一天整周岁),而到了第二天(3月1日),就是n+1:0:1(周岁零1天),没有整周岁这一天。
所以这种情况虽然看起来不连续,视觉上有些“不圆满”,但其实是符合一般习惯的,不属于错误。
期望的正确结果
可见,由于每月天数的不规则,再加上闰年的干扰,想要准确计算生日,主要是天数部分的差值,并不是那么简单。
综合前面的情况,再加上人们对日期差值的习惯性理解,可以把期望的算法结果整理如下:
- 指定生日的精确年龄应该随着待算日期的增长而增长,不能出现重复和减少的情况;
- 如果生日的日部分与待算日期的日部分相同,应记为整月,如果月部分也相同,则记为整年(生日当天记为0时);
- 上述增长,在各级计量单位中必须是连续循环且不中断的,即不应出现类似
00:00:03, 00:00:04, 00:00:06或00:01:30, 00:03:00的情况; - 前述增长过程中,遇到待算日当月最后一天的日部分小于生日的日部分时(如生日为
2001-03-31,待算日为2005-02-28),在次日即下个月第一天时,日部分从1重新开始计数(如本例的结果为03:10:28,到次日2005-03-01时,结果为03:11:01,跳过了03:11:00),即进位后重置的结果只可以是0或1。
明确了期望的结果,再来分析之前的代码什么时候会出现回绕的现象。
- 这种情况只会出现在待算日当月的天数比生日月多时,而不论生日具体是该月的哪一天;
- 待算日当月的日差值递增序列没错;
- 错误回绕的日期会出现在上述月份下一个月的最初几天。
很明显,问题应该出在向月借位时,重置日差值时使用的天数(天数进制)上,也就是这句:SET @diff_days += DAYSOFMONTH(@birthday)。这里用的是生日月的天数,如果换用待算日所在月的天数呢?考虑2月和3月交界的情况:
- 假设 d 是生日的日部分,待算日在2月,2月有28天;
- 则,如果 d > 28 时,
– 2月28日,diff_days = 28 - d < 0, 将被重置为 diff_days = 28 - d + 28 = 56 - d;
– 3月1日,diff_days = 1 - d < 0,将被重置为 diff_days = 1 - d + 31 = 32 - d;
– 带一个值进去比如 d = 30,则2月28日的日差值为 26,而3月1日为 2,出现了中断,与上述期望不符。 - 如果 1 < d <= 28 时,
– 2月28日,diff_days = 28 - d >= 0,不被重置;
– 3月1日,diff_days = 1 - d < 0,将被重置为 32 - d;
– 带一个值进去比如 d = 10,则2月28日的日差值为 18,而3月1日为 22,也出现了中断,仍然与描述的期望不符。
所以简单替换是行不通的,回去看看问题发生的场景,“会出现在待算日当月的天数比生日月多时”,这句是关键,算法需要对这种情况进行区分。
经过一系列推导、证明和测试,得到以下结论(证明从略):
为符合前述需求
- 设生日的日部分为d,待算日的日部分为d1,待算日前一个月的天数为md0;
- 当 d1 >= d 时,无需规格化(借位);
- 当 d1 < d,且 d <= md0 时,因 d1 - d 为负数,需要向月借位,即月差值减去 1,同时 d1 - d 的结果加上 md0;
- 当 d1 < d,且 d > md0 时, 因 d1 - d 为负数,需要向月借位,即月差值减去 1,同时 d1 - d 的结果加上 d,结果也就是 d1。
转换成代码如下:
FUNCTION VARCHAR(20) CalcAge(@birthday AS Date, @calc_day AS Date) RETURNS VARCHAR(20)
BEGIN
IF ( @From > @To ) Return NULL -- 异常参数检查,To必须大于等于From
DECLARE @dy int --结果中年差值的部分
DECLARE @dm int --结果中月差值的部分
DECLARE @dd int --结果中日差值的部分
DECLARE @md int --中间结果,上月天数
SET @dy = YEAR(@To) - YEAR(@From) --计算年差值
SET @dm = MONTH(@To) - MONTH(@From) --计算月差值
SET @dd = DAY(@To) - DAY(@From) --计算日差值
--如果日差值大于 0,无需进行处理,直接输出
--DAYSOFMONTH():获取指定日期所在月份的天数。
--ADDMONTH():将参数1指定的日期,增加参数2指定的月数,返回新日期。
IF @dd < 0 --如果日差值小于 0,需要向月差值借位
BEGIN
SET @dm -= 1 --月差值减 1
SET @md = DAYSOFMONTH( ADDMONTH( @To, -1 ) )
IF @md >= DAY(@From) -- d <= md0 时。
SET @dd += @md -- 日差值 + md0
ELSE -- d > md0 时
SET @dd = DAY(@To) -- 日差值 + d,也就是 DAY(@To)
END
IF @dm< 0 --如果月差值小于 0,需要向年差值借位
BEGIN
SET @dy -= 1 --年差值减 1
SET @dm += 12 --月差值 + 12
END
RETURN FORMAT(@dy, '#00') + ':' + FORMAT(@dm, '00') + ':' + FORMAT(@dd, '00')
END
结论
按YMD格式输出精确年龄的问题,其实也能扩展为按YMD格式输出两个日期间的差值。
在不同场景和需求下,这种差值会有不同的理解和算法,本文中的算法只适用于前面描述的需求场景。
这些理解和需求的差异,主要源于月份天数的复杂性,考虑清楚各种情况之后,最终整理出来的逻辑并不复杂,代码也很简单,可以用各种语言实现,希望能够帮助到有需要的人。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)