ISO 周日历

平年与闰年

当前国际通用的历法是格里历,也叫公历,它将年份分为年长 365 天的平年和年长 366 天的闰年。闰年的规则是:能被 4 整除是闰年,但可以被 100 整除的年份,必须也能被 400 整除才是闰年。闰年的判断代码可以写成:

static inline bool IsLeapYear(int year)
{
    return ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0);
}

格里历的平年闰年如此划分主要是减小与回归年的误差。维基百科上的定义:回归年( tropical year ),也称为太阳年( solar year ),是由地球上观察,太阳平黄经变化 360 °,即太阳再回到黄道(在天球上太阳行进的轨道)上相同的点所经历的时间。

1 回归年 = 365.2421990741 日 = 365 天 5 小时 48 分 46 秒。

当我们把一年定为 365 天时,每积累 4 年会产生 0.2421990741*4=0.9687962964 天的误差。所以我们四年置一闰,把误差减小到 0.9687962964-1=-0.0312037036 天。但是这样的精度还是不够,每过 100 个 4 年,即 400 年会积累误差 -3.12037036 天。所以我们逢四百年一闰,把多出来的 3 天抵消掉。

上面的说法可以换一种表述。四年一闰,平均 1 年 =(365*4+1)/4=365.25 日。逢四百年一闰,平均 1 年 =(365*400+97)/400=365.2425 日。如果以四百年为周期,平均年长跟回归年的误差 365.2421990741-365.2425=-0.0003009259 日。也就是要积累 3000 年才会产生 1 日的误差。格里历以四百年为周期的闰年规则精度足以满足日常使用需求。

从格里历到周日历

给定公历日期是星期几

平年一共有 365 天, 365 除以 7 的商是 52 ,余数是 1 。这意味着如果上一年是平年,那么经过一整个平年后会有 52 个星期的循环,另外增加 1 天。同理,经过一整个闰年则是多了 52 个星期零 2 天。另外格里历是以“四年一闰,四百年一闰”的规则形成四百年为周期的循环,每四百年有 303 个平年, 97 个闰年。多出来的天数 303*1+97*2=497 ,恰好能被 7 整除,也就是说今年的 1 月 1 日的星期数恰好等于四百年后的 1 月 1 日星期数。

从以上的推论我们可以很自然地得出计算某一天是星期几的方法——锚定日期。该方法分两步走。第一步假设已知某一年 1 月 1 日是星期几,并使用模 7 来查找平年( 52 周零 1 天)之后的一年从星期几开始,该日期总是比前一年晚一天,闰年( 52 周零 2 天)之后一年总是比上一年晚两天开始。比如说已知 2001 年 1 月 1 日是星期一,那么 2002 年 1 月 1 日由于经过了 2001 年一整个平年,日期加 1 ,所以 2002 年 1 月 1 日是星期二。同理, 2005 年经过 3 个平年, 1 个闰年,所以 2005 年 1 月 1 日是星期六。更进一步,由于 1 月 1 日星期几的循环是以 400 年为周期的,显然公元 1 年 1 月 1 日也是星期一,这样我们可以轻易地算出公元后任意一年 1 月 1 日是星期几。注意这是理论的计算,因为格里历是从 1582 开始应用推广的,之前用的是儒略历,计算 1582 年以前的日期并没有太大的实际意义。第二步是确定给定的日期是当年的第几天。算出距离 1 月 1 日的天数再模 7 就可以知道给定日期是星期几了。

我们先算第二步,给定的日期是当年的第几天。这部分比较简单,需要注意的是月份范围是从 1 到 12 ,并非从 0 开始。天数也是从 1 开始的。

static const int daysPerMonth[2][12] = {
    {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, // Common year
    {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}  // Leap year
};

static int GetDays(int year, int month, int day)
{
    int days = 0;
    const int leapYear = IsLeapYear(year) ? 1 : 0;

    for (int i = 0; i < month - 1; i++)
    {
        days += daysPerMonth[leapYear][i];
    }

    days += day;
    return days;
}

接着我们回来算第一步。从上文我们已经知道平年加 1 天,闰年加 2 天。那么距离公元 1 年以来共加了 (year-1)+(year-1)/4-(year-1)/100+(year-1)/400 天, year-1 是因为今年还没过完,算的是往年的年数。 (year-1)/4-(year-1)/100+(year-1)/400 算的是闰年的数量。然后加上给定日期距离当年 1 月 1 日的天数再模 7 。这里我们算的是给定日期距离公元 1 年 1 月 1 日相差多少天,并非给定日期本身。最后还要加上 1 ,因为公元 1 年 1 月 1 日是星期一,我们用 1 代表星期一, 7 代表星期天。化简后的的代码如下:

int GetWeekday(int year, int month, int day)
{
    int weekday;
    int days = GetDays(year, month, day);
    weekday = (days + year + (year - 1) / 4 - (year - 1) / 100 + (year - 1) / 400 - 2) % 7 + 1;

    return weekday;
}

给定日期是第几周

接下来是重头戏。我们已经知道了给定日期是星期几,但是怎么知道这个星期几属于今年的第几周呢?这里我们引入 ISO 周日历。 ISO 周日历系统是 ISO 8601 日期和时间标准的一部分,是一种闰周历系统。这个系统主要用在政府和商务的会计年度或者制造业生产日期。传统的格里历每个月的天数不定,按照月度来结算财务或者规划日期有很大的不便利性,周日历以星期为周期,每周固定七天,这种规律对财务计算很友好。有些地方甚至以周或者双周为周期发工资的。 ISO 周日历摈弃了月份的概念,对日期的描述是某年第几周周几,比如 2024-W05-7 表示 2024 年第 5 周星期天。

网络上的对于周日历的说明比较晦涩,计算也很复杂。比如说一平年有 365 天,就是 52 星期零 1 天,那么怎么确定某一星期属于哪一年的?多出来的 1 天怎么办?其实对于 ISO8601 周日历的计算只需要抓住最基本的两点:

  • 每个星期从星期一开始。

  • 每年的第一个星期包含当年的第一个星期四。

所有的计算都是基于这两点的。上面的第二点可能不太好理解。引申出来的意思有两层:一是一星期七天是不可分割的,完整的一个星期要么属于上一年,要么属于今年,或者下一年。不能出现把一星期分成两段,比如星期一属于去年,星期二又归到今年的情况。出现一星期分布在相邻的两年的情况,应该把它归在哪一年?引申出来的第二层就是解决这个问题的。我们用星期四代表整个星期,星期四坐落在哪一年,整个星期就属于哪一年。

下表列出了每年第一周分布的 7 种情况。

Month

December of Last Year

January of Next Year

Day

26

27

28

29

30

31

1

2

3

4

5

6

7

Weekday

2

3

4

5

6

7

1

2

3

4

5

6

7

3

4

5

6

7

1

2

3

4

5

6

7

1

4

5

6

7

1

2

3

4

5

6

7

1

2

5

6

7

1

2

3

4

5

6

7

1

2

3

6

7

1

2

3

4

5

6

7

1

2

3

4

7

1

2

3

4

5

6

7

1

2

3

4

5

1

2

3

4

5

6

7

1

2

3

4

5

6

上面这个表格很重要,之后的讨论都是基于它的。上表的 7 种情况可以分成 3 类:

  1. 第 1 周起始于 1 月 1 日,即不存在跨年的情况。

  2. 第 1 周起始于上一年,即从上一年找补若干天。

  3. 第 1 周起始于本年 1 月 1 日之后,即有若干天被割给上一年。

我们先讨论年初的几天是否属于上一年最后一周的情况。仔细观察上表正文最后三行,我们可以发现 1 月 1 日如果是星期五、星期六或者星期日,那么本周属于上一年。如果 1 月 1 日是星期五,今年有 3 天属于上一年;如果 1 月 1 日是星期六,今年有 2 天属于上一年;如果 1 月 1 日是星期天,今年有 1 天属于上一年。归纳起来,如果给定日期换算成今年的天数,小于等于 8 减去 1 月 1 日的星期数,那么给定日期属于周日历的上一年,并且是上一年的最后一周。这里又会产生一个问题,我们知道格里历一年有 365 天或者 366 天,共有 52 周零 1 天或者 2 天。由于一整个星期是不可分割的,这代表着有些年份会有 52 周,有些年份会有 53 周。怎么确定某一年到底有 52 周还是 53 周?

这个问题我没有找到特别的解法。我用穷举法列出公元 1 年到公元 400 年内的信息,证明了原来代码中对于 52 周或 53 周的查找规律。上文已经讲过了, ISO 周日历也是以 400 年为周期的,虽然下面的代码列举的是公元 1 年到公元 400 年的情况,但是我们加上 400 的倍数可以推广到任意年份。我们回顾 ISO 周日历的第二个基本规定:每年的第一个星期包含当年的第一个星期四。再观察上表,可以发现每年的 1 月 4 日和 12 月 28 日永远属于本年度。根据这个规律我列出了包含 53 周的年份信息,可以看出如果本年 12 月 31 日是星期四,那么本年有 53 周;如果本年是闰年且 12 月 31 日是星期五,那么本年也有 53 周。

int start, end, amount;
int weekday;

for (int i = 1; i < 401; i++)
{
    weekday = GetWeekday(i, 1, 4);
    start = 4 - weekday + 1;
    weekday = GetWeekday(i, 12, 28);
    end = 28 + 7 - weekday;
    amount = GetDays(i, 12, end) - start + 1;
    if (amount == 371) // 53 weeks = 371 days
    {
        weekday = GetWeekday(i, 12, 31);
        printf("year:%03d, Leap:%d, Dec31:%d\n", i, IsLeapYear(i), weekday);
    }
}

这样我们就可以甄别给定日期是否属于上一年,并且是属于第 52 周还是 53 周。

接下来讨论年末的几天是否属于下一年第一周的情况。我们把给定日期换算成是今年的第几天,然后用全年天数减去给定日期天数得出今年剩余天数。仔细观察上表正文第二行,我们可以发现如果剩余 0 天且是星期一,那么最后 1 天属于下一年;看第三行,如果剩余 0 天且是星期一或者 1 天且是星期二,那么最后 2 天属于下一年;如果剩余 0 天且是星期一、 1 天且是星期二、 2 天且是星期三,那么最后三天属于下一年。归纳起来就是剩余天数小于 4 减去给定日期的星期数,那么给定日期属于下一年,且是第 1 周。

最后讨论非年初年末的日期属于第几周。我们首先算出给定日期是今年的第几天,然后根据给定日期的星期数往后填充本周最后几天,再根据 1 月 1 日的星期数往前填充第一周的前面几天。这样操作得到完整地包含一周 7 天的天数,再除以 7 就是周数。最后,如果 1 月 1 日是星期五、星期六或者星期天,这一周属于上一年。所以之前得到的周数还要减去 1 。

Padding First Week

Days of Year

Padding Last Week

最终得到格里历转周日历的算法如下:

void GetWeekDate(int year, int month, int day, int *weekyear, int *week, int *weekday)
{
    int yearNumber, weekNumber = 0, weekdayNumber;
    int dayOfYearNumber = GetDays(year, month, day);
    int Jan1Weekday = GetWeekday(year, 1, 1);
    weekdayNumber = GetWeekday(year, month, day);

    /* Find if year-month-day falls in yearNumber year-1, weekNumber 52 or 53 */
    if (dayOfYearNumber <= (8 - Jan1Weekday) && Jan1Weekday > 4)
    {
        yearNumber = year - 1;
        if (Jan1Weekday == 5 || (Jan1Weekday == 6 && IsLeapYear(year - 1)))
        {
            weekNumber = 53;
        }
        else
        {
            weekNumber = 52;
        }
    }
    else
    {
        yearNumber = year;
    }

    /* Find if year-month-day falls in yearNumber year+1, weekNumber 1 */
    if (yearNumber == year)
    {
        int I = IsLeapYear(year) ? 366 : 365;

        if ((I - dayOfYearNumber) < (4 - weekdayNumber))
        {
            yearNumber = year + 1;
            weekNumber = 1;
        }
    }

    /* Find if year-month-day falls in yearNumber Y, weekNumber 1 through 53 */
    if (yearNumber == year)
    {
        int J = dayOfYearNumber + (7 - weekdayNumber) + (Jan1Weekday - 1);
        weekNumber = J / 7;
        if (Jan1Weekday > 4)
        {
            weekNumber -= 1;
        }
    }

    *weekyear = yearNumber;
    *week = weekNumber;
    *weekday = weekdayNumber;
}

从周日历到格里历

由周日历转成格里历关键在于求出给定日期是今年的第几天,然后可以很简单地按照格里历每月天数的规律转成月份和当月天数表示。我们观察第一张表格可以看到 1 月 4 日总是属于当年的第一周,并且存在以下的规律:当 1 月 4 日是星期一时 1 月前 3 天属于上一年最后一周,是星期二时 1 月前 2 天属于上一年最后一周,是星期三时 1 月第 1 天属于上一年最后一周;当 1 月 4 日是星期四时 1 月 1 日时本年第一周星期一,不存在跨年的情况;当 1 月 4 日是星期五时本年第一周起始于上一年最后一天,是星期六时本年第一周起始于上一年倒数第二天,是星期天时本年第一周起始于上一年倒数第 3 天。

根据周数计算总天数时第一周起始日期总是存在偏移。当 1 月 4 日小于星期四时,由于第一周起始日期总是滞后于 1 月 1 日若干天,因此需要补上对应的偏移天数才是真正属于今年的总天数。当 1 月 4 日大于星期四时,由于第一周起始日期总是超前于 1 月 1 日若干天,因此需要减去对应的偏移天数才是真正属于今年的总天数。当 1 月 4 日恰好是星期四时,第一周周一是 1 月 1 日,不增不加。归纳起来偏移天数的数学表达是 4-weekdayOfJan4

总天数的表达式可以写成 (week-1)*7+weekday+4-weekdayOfJan4 。因为偏移天数的存在,这个结果可能小于 1 或者大于 365 (如果是闰年则是 366 )。小于 1 表示该日期坐落在上一年,用上一年的全年总天数( 365 或这 366 )减去表达式,其结果就是给定日期是上一年的第几天。大于 365 或 366 表示该日期坐落在下一年,表达式的结果减去本年的全年总天数( 365 或 366 ),就是给定日期是下一年的第几天。

知道了总天数我们可以很简单地用最开始的每月天数数组求出具体的月份和当月天数。

void GetCalendarDate(int weekyear, int week, int weekday, int *year, int *month, int *day)
{
    int yearNumber = weekyear, monthNumber = 1, dayNumber = 4;
    int Jan4weekday = GetWeekday(yearNumber, monthNumber, dayNumber);
    int days = (week - 1) * 7 + weekday + 4 - Jan4weekday;
    int I = IsLeapYear(weekyear) ? 366 : 365;

    if (days < 1) /* Week falls on last year */
    {
        yearNumber--;
        days += IsLeapYear(yearNumber) ? 366 : 365;
    }
    else if (days > I) /* Week falls on next year */
    {
        yearNumber++;
        days -= I;
    }
    else
    {
        /* Week falls on this year */
    }

    monthNumber = 0;
    const int leapYear = IsLeapYear(yearNumber) ? 1 : 0;
    while (true)
    {
        if (days > daysPerMonth[leapYear][monthNumber])
        {
            days -= daysPerMonth[leapYear][monthNumber];
            monthNumber++;
        }
        else
        {
            monthNumber++;
            dayNumber = days;
            break;
        }
    }

    *year = yearNumber;
    *month = monthNumber;
    *day = dayNumber;
}

源代码

头文件

#ifndef CALENDAR_H
#define CALENDAR_H

#ifdef __cplusplus
extern "C"
{
#endif

    int GetWeekday(int year, int month, int day);
    void GetWeekDate(int year, int month, int day, int *weekyear, int *week, int *weekday);
    void GetCalendarDate(int weekyear, int week, int weekday, int *year, int *month, int *day);

#ifdef __cplusplus
}
#endif

#endif // CALENDAR_H

源文件

#include "calendar.h"
#include <stdbool.h>

static const int daysPerMonth[2][12] = {
    {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, /* Common year */
    {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}  /* Leap year */
};

static inline bool IsLeapYear(int year) { return ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0); }

/**
* @brief       Calculate days number in this year according year-month-day
* @param[in]   year Year number
* @param[in]   month Month number, in range 1 to 12
* @param[in]   day Day number, in range 1 to 28,29,30,31
* @return      Days number in this year
*/
static int GetDays(int year, int month, int day)
{
    int days = 0;
    const int leapYear = IsLeapYear(year) ? 1 : 0;

    for (int i = 0; i < month - 1; i++)
    {
        days += daysPerMonth[leapYear][i];
    }

    days += day;
    return days;
}

/**
* @brief       Calculate weekday number according year-month-day
* @param[in]   year Year number
* @param[in]   month Month number, in range 1 to 12
* @param[in]   day Day number, in range 1 to 28,29,30,31
* @return      weekday number, in range 1 to 7, Monday=1,Sunday=7
*/
int GetWeekday(int year, int month, int day)
{
    int weekday;
    int days = GetDays(year, month, day);
    weekday = (days + year + (year - 1) / 4 - (year - 1) / 100 + (year - 1) / 400 - 2) % 7 + 1;

    return weekday;
}

/**
* @brief       Calculate ISO week date according year-month-day
*              This algorithm refer to myweb.ecu.edu/mccartyr/ISOwdALG.txt
* @param[in]   year Year number
* @param[in]   month Month number, in range 1 to 12
* @param[in]   day Day number, in range 1 to 28,29,30,31
* @param[out]  weekyear The pointer to year number of ISO8601 week date
* @param[out]  week The pointer to week number of ISO8601 week date
* @param[out]  weekday The pointer to weekday number of ISO8601 week date
* @return      Nothing
*/
void GetWeekDate(int year, int month, int day, int *weekyear, int *week, int *weekday)
{
    int yearNumber, weekNumber = 0, weekdayNumber;
    int dayOfYearNumber = GetDays(year, month, day);
    int Jan1Weekday = GetWeekday(year, 1, 1);
    weekdayNumber = GetWeekday(year, month, day);

    /* Find if year-month-day falls in yearNumber year-1, weekNumber 52 or 53 */
    if (dayOfYearNumber <= (8 - Jan1Weekday) && Jan1Weekday > 4)
    {
        yearNumber = year - 1;
        if (Jan1Weekday == 5 || (Jan1Weekday == 6 && IsLeapYear(year - 1)))
        {
            weekNumber = 53;
        }
        else
        {
            weekNumber = 52;
        }
    }
    else
    {
        yearNumber = year;
    }

    /* Find if year-month-day falls in yearNumber year+1, weekNumber 1 */
    if (yearNumber == year)
    {
        int I = IsLeapYear(year) ? 366 : 365;

        if ((I - dayOfYearNumber) < (4 - weekdayNumber))
        {
            yearNumber = year + 1;
            weekNumber = 1;
        }
    }

    /* Find if year-month-day falls in yearNumber Y, weekNumber 1 through 53 */
    if (yearNumber == year)
    {
        int J = dayOfYearNumber + (7 - weekdayNumber) + (Jan1Weekday - 1);
        weekNumber = J / 7;
        if (Jan1Weekday > 4)
        {
            weekNumber -= 1;
        }
    }

    *weekyear = yearNumber;
    *week = weekNumber;
    *weekday = weekdayNumber;
}

/**
* @brief       Calculate calendar date according ISO8601 week date
* @param[in]   weekyear Year number of week date
* @param[in]   week Week number of week date
* @param[in]   weekday Weekday of week date
* @param[out]  year The pointer to year number of calendar date
* @param[out]  month The pointer to month number of calendar date
* @param[out]  day The pointer to day number of calendar date
* @return      Nothing
*/
void GetCalendarDate(int weekyear, int week, int weekday, int *year, int *month, int *day)
{
    int yearNumber = weekyear, monthNumber = 1, dayNumber = 4;
    int Jan4weekday = GetWeekday(yearNumber, monthNumber, dayNumber);
    int days = (week - 1) * 7 + weekday + 4 - Jan4weekday;
    int I = IsLeapYear(weekyear) ? 366 : 365;

    if (days < 1) /* Week falls on last year */
    {
        yearNumber--;
        days += IsLeapYear(yearNumber) ? 366 : 365;
    }
    else if (days > I) /* Week falls on next year */
    {
        yearNumber++;
        days -= I;
    }
    else
    {
        /* Week falls on this year */
    }

    monthNumber = 0;
    const int leapYear = IsLeapYear(yearNumber) ? 1 : 0;
    while (true)
    {
        if (days > daysPerMonth[leapYear][monthNumber])
        {
            days -= daysPerMonth[leapYear][monthNumber];
            monthNumber++;
        }
        else
        {
            monthNumber++;
            dayNumber = days;
            break;
        }
    }

    *year = yearNumber;
    *month = monthNumber;
    *day = dayNumber;
}

参考文档

https://myweb.ecu.edu/mccartyr/ISOwdALG.txt