第一次看到 doomsday 这个词的时候我第一反应是这该不会是某种末日倒计时程序吧。后来在日历算法的资料堆里翻了一圈才发现这是一套相当精巧的日期推算套路可以让你不看日历、不查手机也能快速说出任意日期是星期几。今天我用 Go 语言把它完整实现了一份源码可以直接运行顺便把公式拆开讲清楚。文章适合两类人一类是单纯想了解经典日期算法的朋友另一类是手头正好有 Go 环境、想找一个小而美的练手项目的开发者。代码量不大但背后的数学规律非常值得玩味。doomsday 算法在中文里常被叫作“末日算法”不过它跟世界末日没有任何关系。它解决的实际问题是给定年、月、日算出这一天是星期几。当然现代编程语言里日期库一行就能搞定这类需求但自己动手实现一遍你会发现日历背后隐藏着很强的周期性规律而且这套规律用来日常心算非常方便跟朋友聊天时报出一个日期、你随口说出星期几效果相当有意思。1. 先搞清楚 doomsday 算法到底在算什么1.1 它不是世界末日而是一套日历心法doomsday 算法最早是上世纪八十年代在数学爱好者圈子里流行起来的名字据说和一个有趣的比喻有关算法中那几个关键日期在英文里常和历史上的“重大节点日”撞在一起传着传着就成了“末日”。实际上这套方法本身非常朴素它只需要三样东西一个或一组“锚定日”一个当年统一的“基准星期”一条简单的取模规则。如果你只是想要一个能算出星期几的程序直接调日期库是最省事的。但 doomsday 算法的价值在于它完全不依赖任何外部库只靠几个整数运算就能完成日期变换而且在 CPU 上连一次除法都用得很克制。Go 语言实现这个算法可以说把“纯计算、零依赖”的特性发挥得很彻底编译出来是一个很小的二进制放到哪里都能跑。1.2 核心思想给每一年找一个公共“星期锚点”日历里最令人头疼的就是月份长短不一、闰年又时不时冒出来。doomsday 算法的思路很聪明与其每个月都从头开始算不如先给每一年找出若干个“锚定日”这些锚定日在同一年里全部落在同一个星期几。一旦拿到这个统一的星期几其他任何日期只需要看它距离本月锚定日相差几天星期跟着平移几天就够了。这四个字说起来容易实际做起来需要解决三个问题该选哪些日期作为每月的锚定日每一年的统一星期几怎么计算闰年一月份和二月份的特殊情况怎么处理这三个问题正好对应算法里的三张“底牌”每月锚定日表、年份末日星期公式、闰年修正规则。理解了这三张牌整个算法就通了。1.3 适用场景和能力边界doomsday 算法适用于格里高利历也就是我们现在日常使用的公历。它对公元 1 年到公元 9999 年的日期都可以正确处理因为这套历法本身就存在 400 年的大周期。代码里如果输入超出这个范围或者输入像 2024 年 2 月 30 日这样的非法日期就需要自己加校验逻辑。这套算法最适合的场景有三个教人理解日历背后的数学结构做嵌入式或离线环境里轻量级的日期换算当作自己心算星期几的练习工具。如果你是在写一个严肃的业务系统我还是建议直接用标准库里的时间函数没必要也不应该造轮子。但作为教学、练习和乐趣doomsday 算法非常有价值。2. 算法核心拆解从年月日到星期几需要经过哪些层2.1 三个公式解决全部问题整个 doomsday 算法可以压缩成三个公式。不要被数学符号吓到每个公式其实都很简单。第一个公式计算某个世纪对应的“世纪锚日”。格里高利历每 400 年会完整循环一次因为 400 年一共包含 146097 天而 146097 恰好能被 7 整除146097 / 7 20871。这意味着 400 年后同一天的星期几会原封不动地回归。所以在设计公式时只需要针对年份的前两位数字也就是世纪值做一次换算。我实现时用下面的公式centuryAnchor ((century % 4) * 5 2) % 7其中 century 就是年份除以 100 的整数部分。以 2024 年为例century 是 2020 % 4 0最终得到 2。以 1985 年为例century 是 1919 % 4 33 * 5 2 1717 % 7 3。第二个公式计算某个年份真正的“末日星期”也就是全年锚定日统一对应的星期几。doomsday (centuryAnchor yy yy / 4) % 7这里的 yy 是年份的后两位yy / 4 是整数除法只保留商。为什么要有 yy / 4因为每过 4 年会出现一个闰年闰年会让平年的星期推进规律多出一天所以四年里平均下来每经过一年末日星期大约推进一天多一点用 yy / 4 正好把闰年的额外推进量补进去。第三个公式从当月锚定日出发推算目标日期是星期几。weekday (doomsday - anchorDate day) % 7anchorDate 是当月锚定日是“几号”day 是你要求的那一天是“几号”。这两个数字相减得到的就是目标日期与锚定日的自然日差。因为锚定日一定等于 doomsday所以从 doomsday 开始平移这么多天就是目标日期的星期。这三个公式加在一起就是算法的主干。接下来需要解决的就是那张锚定日表。2.2 每月锚定日为什么是那些日期锚定日表在非闰年里长这样月份锚定日1 月3 日2 月28 日3 月14 日4 月4 日5 月9 日6 月6 日7 月11 日8 月8 日9 月5 日10 月10 日11 月7 日12 月12 日这些日期并不是随便选的。你如果把它们换算成“这一年的第几天”会很容易看到规律1 月 3 日是第 3 天2 月 28 日是第 59 天两者相差 56 天也就是整整 8 周3 月 14 日是第 73 天与 2 月 28 日相差 14 天整整 2 周4 月 4 日是第 94 天与 3 月 14 日相差 21 天整整 3 周5 月 9 日是第 129 天与 4 月 4 日相差 35 天整整 5 周。继续往后算你会发现任意两个相邻锚定日之间的天数都是 7 的倍数。也就是说这些锚定日在一年内部天然对齐全部落在同一个星期几。这是整个算法能够成立的根本原因。锚定日的记忆也有口诀。偶数月份非常好记4/4、6/6、8/8、10/10、12/12日期和月份相同。奇数月份里5/9、9/5、7/11、11/7 可以联想成“便利店工作时段”我从 9 点上班到 5 点去 7-11 买东西。3 月 14 日是圆周率日也顺带记住了。1 月和 2 月稍微特殊非闰年分别是 3 日和 28 日闰年则顺延一天变成 4 日和 29 日。2.3 闰年里的“1 月例外”是怎么来的闰年只影响 1 月和 2 月的锚定日从 3 月到 12 月完全不用变化。原因在于闰年多出的 2 月 29 日正好插在 2 月和 3 月之间所以 2 月 29 日当天直接成为 2 月的锚定日1 月的锚定日则从 3 日顺延到 4 日。而 3 月 14 日及之后的所有日期在“一年中的第几天”这个维度上非闰年和闰年其实是一致的因为多出的 2 月 29 日已经被跨过去了。在实现里我直接用两张表来区分非闰年: {3, 28, 14, 4, 9, 6, 11, 8, 5, 10, 7, 12} 闰年: {4, 29, 14, 4, 9, 6, 11, 8, 5, 10, 7, 12}两张表只有前两个位置不同剩下十个月完全一致这也正好体现了上面的规律。3. Go 语言实现从零到可运行的完整源码3.1 环境准备与项目初始化Go 的版本要求不高我用的是 1.21但 1.16 以上的版本跑这份代码都没问题。先建一个项目目录mkdir doomsday cd doomsday go mod init doomsday这个项目不需要引入任何第三方依赖整个过程只用标准库。完成后目录里会出现一个 go.mod 文件不用管它直接创建 main.go 开始写代码。3.2 核心函数逐个拆解我按功能把代码拆成四个小函数每个函数只负责一件事isLeap判断闰年daysInMonth返回某年某月的最大天数centuryAnchor计算世纪锚日doomsdayOfYear计算当年的末日星期dayOfWeek计算完整日期对应的星期几。先看闰年判断。格里高利历的规则是能被 400 整除的是闰年能被 100 整除但不能被 400 整除的不是闰年其余能被 4 整除的是闰年。翻译成 Go 代码func isLeap(year int) bool { if year%400 0 { return true } if year%100 0 { return false } return year%4 0 }接着是月份天数。二月单独判断四、六、九、十一月是 30 天其余是 31 天func daysInMonth(year, month int) int { if month 2 { if isLeap(year) { return 29 } return 28 } if month 4 || month 6 || month 9 || month 11 { return 30 } return 31 }然后是世纪锚日公式注意这里要取年份的世纪值。比如 2024 年世纪值是 201985 年世纪值是 19。func centuryAnchor(year int) int { return ((year/100%4)*5 2) % 7 }再往下计算每年的末日星期。这里 yy/4 的整数除法非常重要它统计的是从世纪年开始到当前年份为止大约经过了多少个闰年。因为每个闰年会让末日星期多推进一天所以必须把这个额外天数补进去。func doomsdayOfYear(year int) int { yy : year % 100 return (centuryAnchor(year) yy yy/4) % 7 }最后是综合函数。它先从年份和月份得到当月锚定日再用末日星期和日差推算最终结果。因为 Go 的取模运算在负数时可能得到负数结果所以最后再补一个 7 再取模的操作把结果约束到 0 到 6 之间。func dayOfWeek(year, month, day int) int { leap : 0 if isLeap(year) { leap 1 } anchor : anchorDates[leap][month-1] return (((doomsdayOfYear(year) - anchor day) % 7) 7) % 7 }星期几的显示名字我定义成一个数组下标 0 到 6 分别对应周日到周六var weekdayNames [...]string{ Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, }3.3 把工具包装成命令行程序光有核心函数还不够我顺手做了一个命令行入口。用户直接输入年月日三个参数程序输出对应的星期几。这样既方便测试也方便日常使用。func main() { flag.Parse() if flag.NArg() ! 3 { fmt.Fprintln(os.Stderr, 用法: go run main.go 2024 12 25) os.Exit(1) } year, _ : strconv.Atoi(flag.Arg(0)) month, _ : strconv.Atoi(flag.Arg(1)) day, _ : strconv.Atoi(flag.Arg(2)) if year 1 || year 9999 || month 1 || month 12 || day 1 || day daysInMonth(year, month) { fmt.Fprintln(os.Stderr, 非法日期请检查年、月、日) os.Exit(1) } fmt.Printf(%04d-%02d-%02d - %s\n, year, month, day, weekdayNames[dayOfWeek(year, month, day)]) }这里加上输入校验非常重要。一方面保证月份在 1 到 12 之间另一方面要防止类似 2023 年 2 月 29 日这种不存在的日期混进来。daysInMonth 函数在这里就派上了用场。3.4 完整源码清单把上面的片段组合起来就是完整的源码。我特意保持代码精简注释全部放在关键位置方便你直接复制运行。package main import ( flag fmt os strconv ) var weekdayNames [...]string{ Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, } // anchorDates[0] 是非闰年各月的锚定日anchorDates[1] 是闰年的。 var anchorDates [2][12]int{ {3, 28, 14, 4, 9, 6, 11, 8, 5, 10, 7, 12}, {4, 29, 14, 4, 9, 6, 11, 8, 5, 10, 7, 12}, } func isLeap(year int) bool { if year%400 0 { return true } if year%100 0 { return false } return year%4 0 } func daysInMonth(year, month int) int { if month 2 { if isLeap(year) { return 29 } return 28 } if month 4 || month 6 || month 9 || month 11 { return 30 } return 31 } func centuryAnchor(year int) int { return ((year/100%4)*5 2) % 7 } func doomsdayOfYear(year int) int { yy : year % 100 return (centuryAnchor(year) yy yy/4) % 7 } func dayOfWeek(year, month, day int) int { leap : 0 if isLeap(year) { leap 1 } anchor : anchorDates[leap][month-1] return (((doomsdayOfYear(year) - anchor day) % 7) 7) % 7 } func main() { flag.Parse() if flag.NArg() ! 3 { fmt.Fprintln(os.Stderr, 用法: go run main.go 2024 12 25) os.Exit(1) } year, _ : strconv.Atoi(flag.Arg(0)) month, _ : strconv.Atoi(flag.Arg(1)) day, _ : strconv.Atoi(flag.Arg(2)) if year 1 || year 9999 || month 1 || month 12 || day 1 || day daysInMonth(year, month) { fmt.Fprintln(os.Stderr, 非法日期请检查年、月、日) os.Exit(1) } fmt.Printf(%04d-%02d-%02d - %s\n, year, month, day, weekdayNames[dayOfWeek(year, month, day)]) }运行方式很简单go run main.go 2024 12 25输出结果是2024-12-25 - Wednesday2024 年 12 月 25 日确实是周三程序工作正常。4. 验证与边界测试算法到底靠不靠谱4.1 用标准库做逐日交叉验证手写算法最怕的就是“大部分日期对少数日期错”。为了验证这套实现我写了一个交叉测试函数用 Go 标准库里的 time.Date 作为权威答案把 2000 年到 2099 年之间每一天都跑一遍对比。这个范围内大概有 36524 天可以覆盖平年、闰年、跨月、跨年等各种情况。对比测试的思路非常简单对每一天分别用标准库和我们自己的算法算出星期几两个结果必须完全一致。func verifyAgainstStdlib(startYear, endYear int) { for year : startYear; year endYear; year { for month : 1; month 12; month { maxDay : daysInMonth(year, month) for day : 1; day maxDay; day { want : int(time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC).Weekday()) got : dayOfWeek(year, month, day) if got ! want { fmt.Printf(错误: %04d-%02d-%02d 期望 %s 实际 %s\n, year, month, day, weekdayNames[want], weekdayNames[got]) return } } } } fmt.Printf(%d-%d 全部日期校验通过\n, startYear, endYear) }我把这段代码临时塞进 main 函数里跑了一下最后输出是2000-2099 全部日期校验通过30000 多天全部通过基本可以确认公式推导和实现都正确。4.2 边界日期测试闰年、世纪年、千年之交交叉验证给了我信心但边界用例还是值得单独列一下。下面这些日期各有特点有的落在闰年的 2 月 29 日有的跨越世纪有的正好是千禧年。日期算法输出2024-02-29Thursday2024-01-01Monday2000-02-29Tuesday1900-01-01Monday1985-07-04Thursday2100-01-01Friday其中 1900 年虽然能被 100 整除但不能被 400 整除所以它不是闰年。2100 年同理也不是闰年。这两个日期能检验 centuryAnchor 和闰年判断是否在世纪边界上正确处理。如果我在 isLeap 里忘了“能被 400 整除才算闰年”这个条件1900 年一整个年份的结果都会错。4.3 非法输入的兜底处理命令行版本里已经加了校验逻辑但这里还是想特别提一个容易翻车的点Go 标准库的 time.Date 在遇到非法日期时不会直接报错而是会把日期自动进位。比如time.Date(2023, 2, 29, 0, 0, 0, 0, time.UTC)这段代码不会 panic它会把日期当作 2023 年 3 月 1 日返回。如果你拿它和手写算法做对比2023 年 2 月 29 日在我们算法里应该被拒绝而标准库却返回了 3 月 1 日的星期几两者容易产生混淆。所以在交叉验证的循环里我特意用 daysInMonth 作为遍历上限而不是直接硬编码 31 天就是避免这种自动进位带来的干扰。5. 常见问题与避坑记录5.1 取模负数结果怎么处理Go 语言里的 % 运算符和数学意义上的取模不一样它的结果是余数符号跟被除数保持一致。比如 -3 % 7 在 Go 里得到的是 -3而不是 4。我们的 dayOfWeek 函数里doomsdayOfYear(year) - anchor 完全可能是负数比如 1 月 1 日减 1 月 3 日就是 -2。如果直接写成 (base - anchor day) % 7可能得到负数下标数组直接越界。所以最后的 (((...)%7)7)%7 是必须的先统一取模再加 7 矫正回来。这一行看起来啰嗦但去掉任何一个括号都可能踩坑。5.2 yy/4 为什么必须用整数除法doomsdayOfYear 里的 yy/4 统计的是年份后两位中包含的“完整四年组”数量。以 2024 年为例yy2424/46意味着从世纪初到 2024 年除了每个自然年推进的 24 天之外还有 6 个闰年额外贡献了 6 天。如果不想用整数除法必须先把 yy 转成浮点数再取整但 Go 里整数除法天然就是向下取整直接用最简单。要注意的是不要把 yy/4 和年份后两位的数值混在一起算否则会出现年份推进量和闰年推进量叠加错误。5.3 周日当 0 还是当 7各语言习惯不同我返回的星期几范围是 0 到 60 表示周日。这个约定和 Go 标准库的 time.Weekday 一致方便交叉验证。但换到其他语言时不一定一样比如某些数据库和前端库里周日可能是 1周一可能是 2甚至有的把周一当一周起始返回 0。移植这套算法时第一件事不是复制公式而是确认星期几的映射表。5.4 还能怎么扩展这套实现目前只覆盖格里高利历的公元 1 年到 9999 年。如果你有兴趣可以继续做两个方向的扩展把输出改成中文星期几只需要换掉 weekdayNames 数组增加一个“从年份第几天反推日期”的函数把 doomsday 心算法和儒略日概念结合起来做一个简单的 HTTP 接口把命令行工具包装成 Web 服务。我实际用下来的体会是doomsday 算法最大的乐趣不在写代码而在心算。源码跑通之后我每天随机挑几个日期先在脑子里用公式算一遍再让程序告诉我答案。练上几天“某个纪念日是星期几”这类问题就能随口回答。最后再分享一个小技巧拿这套程序当练习题生成器比对着日历硬背划算得多因为你很快就能从算得慢变成算得准再到算得快。