问题 将天数转换为年数(包括闰年)的高效算法


问题

我正在编写一个用c ++保存日期的类,我发现了以下问题:

我有好几天了 N 自参考日期(在我的情况下将是公元0001年1月1日),包括自参考日以来经过的闰日。 如何将此数字转换为一年 Y,月 M 和白天 D 有效率的

我希望尽可能高效地完成这项工作,因此最佳实现显然会具有O(1)复杂性。

接下来的部分将解释我已经学到的一些东西。

闰年

要确定一年是否跳跃,有一些规则:

  1. 可被4整除的年份是飞跃
  2. 规则1的例外:可以被100整除的年份不是跳跃
  3. 规则2的例外:可以被400整除的年份是飞跃

这将转换为这样的代码:

bool IsLeapYear(int year)
{
    // Corrected after Henrick's suggestion
    if (year % 400 == 0) return true;
    if ((year % 4 == 0) && (year % 100 != 0)) return true;
    return false;
}

计算一年前飞跃多少年的有效方法是:

int LeapDaysBefore(int year)
{
    // Years divisible by 4, not divisible by 100, but divisible by 400
    return ((year-1)/4 - (year-1)/100 + (year-1)/400);
}

计算月份

一旦我找到了这一年,我可以计算到当年的天数,我可以从N中减去这个数字。这将给我一年中的这一天。

保持每月开始的日期编号表,我们可以轻松计算月份。我还创建了一个函数,如果年份是跳跃,则将加1,月份大于或等于2。

// What day each month starts on (counting from 0)
int MonthDaySt[] = { 0, 31, 59, 90, 120, 151, 181, 212, 
    243, 273, 304, 334, 365 };

int MonthDayStart(int month, bool leap)
{
   if (leap && month >= 2) return MonthDaySt[month]+1;
   return MonthDaySt[month];
}

我的想法

我的算法非常复杂,看起来像这样:

void GetDate(int N, int &Y, int &M, int &D)
{
    int year_days;

    // Approximate the year, this will give an year greater or equal
    // to what year we are looking for.
    Y = N / 365 + 1;

    // Find the actual year, counting leap days
    do {
        Y--;

        // Calculate the actual number of days until the
        // approximate year
        year_days = Y * 365 + LeapDaysBefore(year);

    } while (year_days > N);

    // Add 1, because we start from year 1 AD (not 0)
    Y++;

    // Calculate month
    uint64_t diff = N - year_days; // Will give us the day of the year
    bool leap = IsLeapYear(Y);  // Is current year leap?

    // Use table to find month
    M = 0;
    while (MonthDayStart(M, leap) <= diff && M <= 12)
        M++;

    // Calculate day
    D = diff - MonthDayStart(M - 1, leap) + 1;
}

该函数可能有一些错误(例如,当N为0时,它不起作用)。

其他说明

我希望我的算法仍然正确,因为我对这个问题做了一些改动。如果我错过了什么,或者出了什么问题,请告诉我修改它。抱歉这个长期的问题。


8404
2017-07-23 09:28


起源

您是否考虑过将年数除以400,然后考虑每100年,然后找到确切的年份? - nhahtdh
@nhahtdh是的,我想到了,但还没有实现它。 - Tibi
咨询来源: emr.cs.uiuc.edu/home/reingold/calendar-book/index.shtml - High Performance Mark
我很好奇:您多久调用一次此功能会对程序的速度产生重大影响? - stefan
运用 boost::gregorian::date 可能是从这里出发的最佳方式。 - Alexandre C.


答案:


这里有几点建议。注意:对于本练习,我将假设何时 N=0 那 Y % 400 == 0

1: 每400年有固定的天数 (400 * 365) + 100 + 1 - 4

+100 是为了闰年, +1 是每400年闰年和 -4 是因为每100年没有闰年。

所以你的第一行代码将是:

GetDate(int N, int &Y, int &M, int &D) {
  const int DAYS_IN_400_YEARS = (400*365)+97;
  int year = (N / DAYS_IN_400_YEARS) * 400;
  N = N % DAYS_IN_400_YEARS;

2: 如果您将3月1日视为一年的第一天,您可以让您的生活更轻松

3: 添加到代码中 (1),我们可以锻炼一年。请记住,每四个世纪都以闰年开始。因此,您可以使用以下内容完成年度计算:

  const int DAYS_IN_100_YEARS = (100*365) + 24;
  year += 100 * (N / DAYS_IN_100_YEARS) + (N < DAYS_IN_100_YEARS ? 1 : 0); // Add an extra day for the first leap year that occurs every 400 years.
  N = N - (N < DAYS_IN_100_YEARS ? 1 : 0);
  N = N % DAYS_IN_400_YEARS;

4: 现在你已经整理了几年,其余的很容易就像馅饼一样(只记得 (2),这个过程很简单)。

或者你可以使用 提高::日期


3
2017-07-23 10:09



有些事我不明白......为什么 DAYS_IN_100_YEARS = (100*365) - 24?不应该 +24?当你把它添加到年份时,你应该乘以 100。 - Tibi
刚修好了 - Keldon Alleyne
最好的答案,因为它实际上帮助我解决了问题,而不是像其他答案一样添加其他问题。 - Tibi


要使用一个老笑话的妙语,“我不会从这里开始”。

你会想要在“现代”时代之前阅读有关日历的各种变化,例如,1752年发生的事情


5
2017-07-23 09:45



1752年公历中采用格里高利历只是由大英帝国采用。它在其他地方的采用发生在不同的年份,或在一些地方多年。 - Some programmer dude
朱利安 - 格里高利历的问题。如果我们假设一切都在格里高利历中,我认为这不是问题。转换也不是太复杂。 - nhahtdh
我真想知道这个笑话是什么! - El Ronnoco
@Tibi - 论证就是它 不 方便,因为你必须在0001年1月1日之前决定你的意思。这不是一个历史日期,所以你必须弥补它。 1970年或2000年作为日志日期的基准年有什么问题? - Bo Persson
@Tibi: 也可以看看。 - BlueRaja - Danny Pflughoeft


这个

bool IsLeapYear(int year) 
{ 
    if ((year % 4 == 0) && (year % 100 != 0) && (year % 400 == 0)) return true; 
    else return false; 
}

是不正确的。它回来了 false 2000年。更好:

bool IsLeapYear(int year) 
{ 
    if (year % 400 == 0) return true; 
    if ((year % 4 == 0) && (year % 100 != 0)) return true; 
    return false; 
}

1
2017-07-23 09:33



我想我知道为什么,因为这些不可能同时发生: (year % 100 != 0) && (year % 400 == 0)。我纠正了原来的问题。 - Tibi


显然,瓶颈是年度计算。 我建议你这样做。 初始化日历时,通过将天数除以365来估算年份(非常粗糙)。 之后,在此估算之前预先形成所有闰年的列表。它应该相当快,因为​​你不需要计算 所有 其中,每次只增加4年。此外,在做这些时,请计算你有多少。实际上,你甚至可以用更大的包装来计算它们(即每400年有100个闰年),但你需要仔细检查闰年例外,而不是跳过其中的一些。

在此结束时,您将获得年度的粗略估计,以及之前所有闰年的数量。现在,您可以非常简单地计算精确的年份,而无需迭代任何事情:

leapYearCount * 366 + (lastCalculatedYear - leapYearCount) * 365

1
2017-07-23 09:41



更正,每400年有97个闰年。消除可被100整除的那些,并将可被400整除的年份加回去,这得到97。 - Tibi
@Tibi,是的,那是什么的 you will need to check for the the leap year exceptions carefully, not to skip some of them. 部分是关于:P - SingerOfTheFall


让我简化问题,我不会考虑解释的例外情况。 每4年发生一次跳跃,如果你有365 * 5天,必须有一个闰年(除非应用例外2)。您可以使用除法来获得闰年数(如果忽略例外)。

然后,您可以轻松地使用除法和余数来获得非闰年/月/日。

使用相同的基本直觉来解决问题 例外1 (如果年数是100的倍数,那么也检查一下 例外2

  1. 可被4整除的年份是飞跃
  2. 规则1的例外:可以被100整除的年份不是跳跃
  3. 规则2的例外:可以被400整除的年份是飞跃

1
2017-07-23 09:47



所以你说我应该忽略大约一年的例外,然后找到真实年份? - Tibi
不,永远不要忽视例外情况。我只向您展示了您可以使用第一条规则获得闰年数。现在,艰巨的任务是检查如何“交叉”规则,以便保证两个例外。 - Nuno Aniceto


自参考日期起我有多天N(在我的情况下会是 公元0001年1月1日)...

在这种情况下,应用4-100-400规则并查找月份长度的“效率”不是您的主要问题。请注意将今天的公历应用于其成立之前的日期所固有的多个问题,  Gregorian没有统一引入的事实。 (*)

维基百科 是一个  开始  对一个非常复杂的主题。

(*):根据国家,在1582年10月15日至1923年2月15日之间的任何地方,分别为实际上根本没有。


1
2017-07-23 09:55



我使用(预感)格里高利历,因为它是最简单的方式,我甚至不想打扰其他日历。另外,为方便起见,我选择参考日期,'0'表示第一年的第一天。 - Tibi
@Tibi:请注意你的计算是错误的。 - DevSolar
在引入日历之前的几年中,这将是错误的,但这超出了我的申请目的。 - Tibi
@Tibi:那么为什么选择1 A.D.作为第一行的参考日期,如果前格里高利日期超出范围? - DevSolar
我没有说前格列高利日期超出范围,我的意思是,对于我的申请,他们不必100%准确。引入格里高利历后的日期是准确的,但在此之前的日期不一定如此。我正在尝试创建一个游戏,我可能需要一些较旧的日期,具体取决于故事情节,但我还没有确定故事。它不是一个历史数据库或任何必须100%精确的东西。 - Tibi


多年来,我在解决格里高利日期问题上做了一些失败的尝试。我大约15年前开发了这个代码,并且它继续表现良好。因为我很久以前就编写了这段代码的版本,所以它在本机C中,但很容易编译成C ++程序。如果您愿意,可以随意将它们包装在Date类中。

我的代码基于将所有闰年规则组合成一个400年的周期。根据格列高利闰年规则,每400年周期恰好有146,097天。

我采用的优化是将1月和2月移至上一年末。这使得闰日(如果存在)总是落在一年的最后一天。这允许我构建一个表(dayOffset),它提供从3月1日开始的距离。因为闰日会在结束时下降,所以此表对于跳跃年和非跳跃年都是准确的。

我将从头文件开始。

#if !defined( TIMECODE_H_ )
#define TIMECODE_H_ 1

#if defined(__cplusplus)
extern "C" {
#endif

int dateCode( int month, int dayOfMonth, int year );

void decodeDate( int *monthPtr, int *dayOfMonthPtr, int *yearPtr, int dateCode );

int dayOfWeek( int dateCode );

int cardinalCode( int nth, int weekday, int month, int year );

enum Weekdays { eMonday, eTuesday, eWednesday, eThursday, eFriday, eSaturday, eSunday };

#if defined(__cplusplus)
}
#endif

#endif

API由四种方法组成:dateCode()计算格里高利日期的日期代码。 decodeDate()根据日期代码计算格里高利月,日和年。 dayOfWeek()返回日期代码的星期几。 cardinalCode()返回特定月份的“基数”日期的日期代码(例如,2014年8月的第2个星期三)。

这是实施:

#include <math.h>

enum
{
   nbrOfDaysPer400Years = 146097,
   nbrOfDaysPer100Years = 36524,
   nbrOfDaysPer4Years = 1461,
   nbrOfDaysPerYear = 365,
   unixEpochBeginsOnDay = 135080
};

const int dayOffset[] =
{
   0, 31, 61, 92, 122, 153, 184, 214, 245, 275, 306, 337, 366
};

/* ------------------------------------------------------------------------------------ */
int mod( int dividend, int divisor, int* quotientPtr )
{
   *quotientPtr = (int)floor( (double)dividend / divisor );
   return dividend - divisor * *quotientPtr;
}

/* ------------------------------------------------------------------------------------ */
int dateCode( int month, int dayOfMonth, int year )
{
   int days;
   int temp;
   int bYday;
   /*
   we take the approach of starting the year on March 1 so that leap days fall
   at the end. To do this we pretend Jan. - Feb. are part of the previous year.
   */
   int bYear = year - 1600;
   bYday = dayOffset[ mod( month - 3, 12, &temp ) ] + dayOfMonth - 1;
   bYear += temp;

   bYear = mod( bYear, 400, &days );
   days *= nbrOfDaysPer400Years;

   bYear = mod( bYear, 100, &temp );
   days += nbrOfDaysPer100Years * temp;

   bYear = mod( bYear, 4, &temp );
   days += nbrOfDaysPer4Years * temp + nbrOfDaysPerYear * bYear + bYday -
      unixEpochBeginsOnDay;

   return days;
}

/* ------------------------------------------------------------------------------------ */
int dayOfWeek( int dateCode )
{
   int temp;
   return mod( dateCode + 3, 7, &temp );
}

/* ------------------------------------------------------------------------------------ */
void decodeDate( int *monthPtr, int *dayOfMonthPtr, int *yearPtr, int dateCode )
{
   int diff;
   int diff2;
   int alpha;
   int beta;
   int gamma;
   int year;
   int temp;

   /* dateCode has the number of days relative to 1/1/1970, shift this back to 3/1/1600 */
   dateCode += unixEpochBeginsOnDay;
   dateCode = mod( dateCode, nbrOfDaysPer400Years, &temp );
   year = 400 * temp;
   dateCode = mod( dateCode, nbrOfDaysPer100Years, &temp );
   /* put the leap day at the end of 400-year cycle */
   if ( temp == 4 )
   {
      --temp;
      dateCode += nbrOfDaysPer100Years;
   }
   year += 100 * temp;
   dateCode = mod( dateCode, nbrOfDaysPer4Years, &temp );
   year += 4 * temp;
   dateCode = mod( dateCode, nbrOfDaysPerYear, &temp );
   /* put the leap day at the end of 4-year cycle */
   if ( temp == 4 )
   {
      --temp;
      dateCode += nbrOfDaysPerYear;
   }
   year += temp;

   /* find the month in the table */
   alpha = 0;
   beta = 11;
   gamma = 0;
   for(;;)
   {
      gamma = ( alpha + beta ) / 2;
      diff = dayOffset[ gamma ] - dateCode;
      if ( diff < 0 )
      {
         diff2 = dayOffset[ gamma + 1 ] - dateCode;
         if ( diff2 < 0 )
         {
            alpha = gamma + 1;
         }
         else if ( diff2 == 0 )
         {
            ++gamma;
            break;
         }
         else
         {
            break;
         }
      }
      else if ( diff == 0 )
      {
         break;
      }
      else
      {
         beta = gamma;
      }
   }

   if ( gamma >= 10 )
   {
      ++year;
   }
   *yearPtr = year + 1600;
   *monthPtr = ( ( gamma + 2 ) % 12 ) + 1;
   *dayOfMonthPtr = dateCode - dayOffset[ gamma ] + 1;
}

/* ------------------------------------------------------------------------------------ */
int cardinalCode( int nth, int weekday, int month, int year )
{
   int dow1st;
   int dc = dateCode( month, 1, year );
   dow1st = dayOfWeek( dc );
   if ( weekday < dow1st )
   {
      weekday += 7;
   }
   if ( nth < 0 || nth > 4 )
   {
      nth = 4;
   }
   dc += weekday - dow1st + 7 * nth;
   if ( nth == 4 )
   {
      /* check that the fifth week is actually in the same month */
      int tMonth, tDayOfMonth, tYear;
      decodeDate( &tMonth, &tDayOfMonth, &tYear, dc );
      if ( tMonth != month )
      {
         dc -= 7;
      }
   }
   return dc;
}

效率的一个问题很明显就是mod()函数。正如您所料,它提供了两个积分红利的商和余数。 C / C ++提供了模数运算符(%),这似乎是一个更好的选择;但是,标准没有具体说明此操作应如何处理负红利。 (看到 这里 了解更多信息)。

可能有一种便携式解决方案,它使用有效的整数数学;但是,我在这里选择的效率略低,但在所有平台上都保证正确。

日期代码只是从基准日期开始的天数偏移量。我选择了1600年3月1日,因为它是400年格里高利周期的开始,这个周期足够早,所以我们可能遇到的所有日期都会产生一个正整数的日期代码。但是,基准日期之前的日期代码没有任何错误。由于我们使用稳定/便携式模运算,因此所有数学运算都适用于负数日期代码。

有些人不喜欢我的非标准基准日期,所以我决定采用标准的Unix纪元,从1970年1月1日开始。我定义了unixEpochBeginsOnDay来偏置日期代码以在所需的日期开始。如果要使用其他基准日期,则应将此值替换为您喜欢的值。

计算日期代码就像将月份,dayOfMonth和year传递给dateCode()一样简单:

int dc = dateCode( 2, 21, 2001 );  // returns date code for 2001-Feb-21

我编写了dateCode,以便它接受超出month和dayOfMonth范围的值。您可以将月份视为一个加上给定年份的1月之后的整数月份。这里有一些测试来证明:

assert(dateCode( 14, 1, 2000 ) == dateCode( 2, 1, 2001 ));
assert(dateCode( 5, 32, 2005 ) == dateCode( 6, 1, 2005 ));
assert(dateCode( 0,  1, 2014 ) == dateCode(12, 1, 2013 ));

使用非标准月份和dayOfMonth值调用dateCode,然后使用decodeDate进行转换,是规范日期的有效方法。例如:

int m, d, y;
decodeDate( &m, &d, &y, dateCode( 8, 20 + 90, 2014 ));
printf("90 days after 2014-08-20 is %4d-%02d-%02d\n", y, m, d);

输出应该是:

2014-08-20之后90天是2014-11-18

decodeDate()始终为month和dayOfMonth生成规范值。

dayOfWeek()只返回dateCode的模数7,但我不得不将dateCode偏向3,因为1970年1月1日是星期四。如果您希望在与星期一不同的一天开始您的一周,那么请修复工作日枚举并根据需要更改偏差。

cardinalCode()提供了这些方法的有趣应用。第一个参数提供月份的周数(“nth”),第二个参数提供工作日。所以要找到2007年8月的第四个星期六,你会:

int m, d, y;
decodeDate( &m, &d, &y, cardinalCode( 3, eSaturday, 8, 2007 ) );
printf( "%d/%02d/%d\n", m, d, y );

这产生了答案:

2007年8月25日

请注意,上例中的第n个参数3指定了第四个星期六。我争论这个参数是基于零还是基于一个。无论出于何种原因,我决定:0 =第一,1 =第二,2 =第三,等等。即使是最短的月份,每个工作日也会发生四次。值4具有特殊含义。人们会期望它返回所请求的工作日的第五次出现;但是,由于这个月可能会或可能不会有第五次出现,我决定退回 持续 发生了一个月。

例如,要显示明年每个月的最后一个星期一:

int i, m, d, y;
for (i=1; i <= 12; ++i) {
    decodeDate( &m, &d, &y, cardinalCode( 4, eMonday, i, 2015 ) );
    printf( "%d/%02d/%d\n", m, d, y );
}

最后一个例子,说明cardinalCode()的一个用途,显示下一次大选前的天数:

#include <stdio.h>
#include <time.h> /* only needed for time() and localtime() calls */
#include "datecode.h"

void main()
{
   int eYear, eday, dc;
   int eY, eM, eD;
   time_t now;
   struct tm bdtm;

   time(&now);
   if (localtime_r(&now, &bdtm) == NULL) {
       printf("Error\n");
       return 1;
   }
   eYear = bdtm.tm_year + 1900;
   dc = dateCode(bdtm.tm_mon + 1, bdtm.tm_mday, eYear);
   if ((eYear % 2) != 0) {
       ++eYear;
   }
   for(;;) {
       eday = cardinalCode(0, eTuesday, 11, eYear);
       if (eday >= dc) break;
       eYear += 2;    /* move to the next election! */
   }
   decodeDate(&eM, &eD, &eY, eday);
   printf("Today is %d/%02d/%d\neday is %d/%02d/%d, %d days from today.\n",
           bdtm.tm_mon + 1, bdtm.tm_mday, bdtm.tm_year + 1900,
           eM, eD, eY, eday - dc);
}

1
2017-08-20 22:44





为了加快年份的计算,您可以构建查找表

int[] YearStartDays =
{
    0,                     // 1 AD
    365,                   // 2 AD
    365 + 365,             // 3 AD
    365 + 365 + 365,       // 4 AD
    365 + 365 + 365 + 366, // 5 AD (4 was a leap year)
    /* ... */
};

然后,您可以在此数组中进行二进制搜索,即O(log N),而不是当前年份查找算法的O(N)。


0
2017-07-23 09:45



我想到了这种方法,并且由于跳跃规则每400年重复一次,它可以起作用。然而,它换取内存的速度。 - Tibi
问题是第4年不是闰年,因为闰年​​还没有发明。如果有的话,这是值得怀疑的 是 一年4. :-) - Bo Persson


bool IsLeapYear(int year)
{
    boost::gregorian::date d1(year, 1, 1);
    boost::gregorian::date d2 = d1 + boost::gregorian::years(1);
    boost::gregorian::date_duration diff = d2 - d1;
    return diff.days() != 365;
}

0
2017-08-20 20:18



这并没有回答完整的问题,它只改变了函数的一部分。 - Mike Precup


你为什么要重新发明日期?

日期数学很好理解。标准C库(正确的,C,而不仅仅是C ++)具有多年的日期功能。

正如其他人所指出的那样,提升日期等级也设计得很好并且易于使用。

在寻找答案时,第一个问题应该是问题已经解决了。这个问题已经解决了很多年。


-3
2017-07-23 15:36