简介:面向嵌入式与C/C++开发者的农历历法数据压缩方案,聚焦农历月份、二十四节气及梅雨、数九、伏日等传统历法信息的极致存储。其核心思路是将上述信息压缩至每年仅11字节,并配套可自定义年份范围的数据提取工具,省去手工翻阅万年历逐条录入的繁琐,也便于在资源受限环境中实现公农历互转与节气查询。压缩包共9个文件、约668KB,以C源码与头文件为主体,辅以Visual Studio解决方案及工程配置文件,可直接在VC环境中编译调试,另含说明文档与内嵌压缩包,便于快速理解数据组织方式与提取流程。TestLunar示例工程展示了公农历转换和提取工具的调用方式,适合研究历法算法、开发日历应用或需要轻量级时间数据的开发者参考复用。目前已有482人学习,可作为农历数据压缩与C语言工程实践的入门样本。
1. 农历节气的存储困境:为什么一张表能压到几 KB
农历和节气的压缩算法里有一个反直觉的结论:农历根本算不出来,只能查表;但查表并不代表笨重。一张覆盖数百年的农历节气压缩表,可以塞进几 KB,比一张图标素材还小。原因不在于数据规律到能拟合出公式,而在于它的自由度远低于表面观感。一个农历年真正需要记住的信息只有十七位,一个节气年真正需要记住的只有几十位,剩下的全是冗余。搞懂这件事的价值很实际:任何要在离线环境里做日期换算的系统,最终都会撞上同一个问题——怎么把几百年的历法数据塞进有限的存储里,还要保证查得对、查得快、出错时能定位。
2. 农历年的位域编码:一个闰月和十三个月长怎么塞进 17 位
2.1 农历的四个自由度,实际只有两个是真的
看一个农历年,直觉上要记四件事:这一年有没有闰月、闰哪个月、每个月是二十九天还是三十天、正月初一落在公历哪一天。前两件合起来是一个 0 到 12 的整数,第三件是一串 29/30 的序列,第四件看起来是个独立的日期。
关键在于第四件不是独立的。农历月的定义是「朔日所在的那一天为初一」,朔是一个天文时刻,两个相邻朔之间平均 29.5306 天,所以月长只能是 29 或 30。一旦知道某一年的正月初一是哪一天,又知道这一年的每个月长,下一年的正月初一就是「本年正月初一 + 本年总天数」,纯整数累加,没有误差累积。整张表只需要一个绝对基准,后面全是增量。
所以一个农历年的真实信息量是:闰月位置(0 到 12,四种可能之外还有「无闰月」共十三种),加上十三个月的月长标志。闰月位置需要 4 位,十三个月长需要 13 位,一共 17 位。这就是全部。
这里最容易产生的误解是「19 年 7 闰,可以用固定周期推」。19 个回归年是 6939.60 天,235 个朔望月是 6939.69 天,两者差 0.09 天,三百多年就攒出一天。闰月的实际规则是「无中气置闰」——一个农历月如果整月不含中气,就设为闰月。中气是太阳视黄经落在 30 的整数倍上的时刻,受地球轨道摄动影响;朔受月球摄动影响。两个都不是均匀流逝的量,所以农历是天文历而非算术历,没有可闭合的公式。格里高利历能写成y/4 - y/100 + y/400,农历写不出来。这就是必须查表的根本原因。
2.2 位布局:13 位月长与 4 位闰月的位置安排
把上面的分析落成整数布局,顺序很重要。闰月插在第 n 个月之后,那么十三个位里第 n 个位置应该正好对应「闰 n 月」,否则解包时要做额外换算。
# 农历年记录:一个 17 位无符号整数 # bit 0..12 : 十三个月的月长标志,1 = 30 天(大月),0 = 29 天(小月) # 无闰月时只用 bit 0..11,bit 12 恒为 0 # 有闰月时,bit(leap-1) 对应闰月本身,其后各位顺延 # bit 13..16 : 闰月位置,0 = 无闰月,1..12 = 闰几月 LEAP_SHIFT = 13 LEAP_MASK = 0x0F MONTH_MASK = 0x1FFF def pack_year(leap: int, month_len: list[int]) -> int: """把一年的农历结构压成 17 位整数。 leap: 0 表示无闰月,1..12 表示闰几月 month_len: 有闰月时长度 13,无闰月时长度 12,元素只能是 29 或 30 """ if leap == 0: assert len(month_len) == 12 else: assert len(month_len) == 13 bits = 0 for i, n in enumerate(month_len): assert n in (29, 30) if n == 30: bits |= 1 << i # 大月置 1,小月保持 0 return (leap << LEAP_SHIFT) | (bits & MONTH_MASK)这段代码有个隐含约定值得说清楚:month_len的下标不是「农历月份号」,而是「位序号」。闰四月那一年,month_len[0..3]是正月到四月,month_len[4]是闰四月,month_len[5]才是五月。这样解包时不需要做下标到月份号的映射,直接顺序读就行。
2.3 解包与推演:从一个整数还原出整年天数
解包要同时解决两件事:把 17 位拆回月长列表,以及把「正月初一是哪天」沿着年份链条推下去。
def unpack_year(packed: int) -> tuple[int, list[int]]: """拆出 (闰月位置, 月长列表)""" leap = (packed >> LEAP_SHIFT) & LEAP_MASK bits = packed & MONTH_MASK n = 13 if leap else 12 return leap, [30 if (bits >> i) & 1 else 29 for i in range(n)] def walk_years(base_jdn: int, packed_years: list[int]) -> list[int]: """从基准年正月初一的儒略日数出发,推出每一年的正月初一日期。 base_jdn 来自表头,是基准年正月初一的儒略日数。 返回值长度与 packed_years 相同。 """ out, cur = [], base_jdn for p in packed_years: out.append(cur) _, lens = unpack_year(p) cur += sum(lens) # 本年总天数只能是 353/354/355/383/384/385 return outsum(lens)的取值只有六种。十二个月的年份是 353 到 355 天,十三个月是 383 到 385 天。这个约束本身就是一层校验:任何解出来不在这个集合里的记录,说明表被改坏了或者边界读串了。
推演链条的代价也要讲清楚。这种方式下第 n 年的正月初一依赖前面所有年的数据,中间任何一年错一位,后面的日期全错,而且错误不会自暴露——它只是让春节从 2 月 8 日慢慢漂到 2 月 9 日、2 月 10 日,看起来像另一份历法。这是使用这类压缩表最需要预先设计防线的地方,后面讲提取工具时会专门处理。
十七位一年,八百年就是一万三千六百位,一千七百字节。到这里已经比任何「每年存一个结构体」的朴素做法小一个数量级以上,但还没到极限,因为十三个月的月长序列在相邻年份之间高度相关,下一章会把这份相关性也榨掉。
3. 节气的压缩:二十四点太阳黄经为什么能用 2 位偏移表达
3.1 节气比农历好压,差别在连续性
农历的难点是每个月都要重新问一次「朔在哪一刻」,二十四个节气问的是另一个问题:太阳视黄经每走过 15 度就是一个节气。相邻两个节气的平均间隔是 365.2422 / 24 = 15.2184 天,这意味着节气在公历里的日期漂移是被约束住的。
小寒永远在 1 月 5 日到 1 月 7 日之间,大寒永远在 1 月 20 日或 21 日,立春在 2 月 3 日到 2 月 5 日之间。绝大多数节气的浮动范围不超过三天。这种「围绕名义日期小幅抖动」的结构,压缩起来比农历容易得多——不需要表达任意序列,只需要表达偏离。
常见的做法是给每个节气定一个名义日期,然后只存偏移量。二十四节气按顺序排下来,名义日期选在出现频率最高的那一天:
# 二十四节气名义日期,按小寒到大雪的固定顺序 # 每个节气只记录它在公历中的「最常见日期」 NOMINAL = [ (1, 6), (1, 20), (2, 4), (2, 19), (3, 5), (3, 20), (4, 5), (4, 20), (5, 5), (5, 21), (6, 5), (6, 21), (7, 7), (7, 22), (8, 7), (8, 23), (9, 7), (9, 23), (10, 8), (10, 23),(11, 7), (11, 22),(12, 7), (12, 22), ]这份表是全局常量,只存一份,二十四字节。每一年的信息量就变成了「这二十四天各自偏了几天」。
3.2 2 位偏移的边界与例外表
偏移量理论上可以是负的。小寒可能落在 1 月 5 日,相对名义的 1 月 6 日是 -1;冬至可能落在 12 月 21 日,相对名义的 12 月 22 日也是 -1。往上,大寒偶尔到 1 月 21 日,立春偶尔到 2 月 5 日,偏移 +1。真正到 +2 的情况很少,但生成几百年数据时会零星出现。
用 2 位表达四个值,映射成 -1、0、+1、+2 刚好覆盖绝大多数年份:
def pack_term_year(offsets: list[int]) -> int: """把 24 个节气偏移打包成 48 位整数。 offsets: 长度 24,每个元素在 -1..2 之间,其余值说明该年需要走例外表 """ assert len(offsets) == 24 v = 0 for i, off in enumerate(offsets): assert -1 <= off <= 2, f"第 {i} 个节气偏移 {off} 超出 2 位范围" v |= (off + 1) << (i * 2) # +1 映射到 0..3 return v一年四十八位,六字节。八百年四千八百字节。比农历每年十七位贵得多,因为节气有二十四个独立的观测点,而农历一年只有两个自由度。
偏移一旦超出 -1 到 +2 这个范围,就不能硬编码。正确的做法不是把位宽加到 3 位——那会让每年多出二十四位,等于为了极少数异常年份把所有年份的成本抬高五成——而是维护一张例外表,只记录「哪一年哪几个节气超范围」,主表里对应位置填一个占位值,读取时先查例外表再覆盖。
这背后是一个通用判断:压缩格式的位宽应该按数据的众数设计,长尾用旁路表接住。位宽赌的是分布,例外表赌的是稀疏。两者结合,才叫极限压缩;单纯把位宽开大,只是把问题换个地方摆着。
3.3 节气日期的还原与闰年干扰
解包出来的偏移要还原成具体日期,这一步要处理跨月问题。小寒名义日期是 1 月 6 日,偏移 -1 变成 1 月 5 日,还在 1 月;但清明名义 4 月 5 日,偏移 -1 到 4 月 4 日,也没跨月。真正麻烦的是冬至在 12 月 22 日附近,偏移 +2 会到 12 月 24 日,不跨年。二十四节气里没有哪个节气的偏移会把日期挤出当月的合理范围,所以可以直接对「年内第几天」做加减。
import datetime def term_dates(year: int, packed: int) -> list[datetime.date]: """把一年的 24 个节气还原成公历日期""" result = [] for i, (m, d) in enumerate(NOMINAL): off = ((packed >> (i * 2)) & 0x03) - 1 base = datetime.date(year, m, d) result.append(base + datetime.timedelta(days=off)) return result用datetime.timedelta做加减,闰年二月二十九日的问题自动交给标准库处理。这里有个容易被忽略的细节:如果按「年内第几天」存偏移,闰年之后所有节气的序号都会整体后移一位,解包时必须先判断该年是否闰年再做序号到日期的换算。直接存「月 + 日 + 偏移」反而绕开了这个坑,代价是名义日期表要多存月份信息,二十四字节变成四十八字节——全局一份的东西,这点开销完全不值一提。
节气这块还有一层冗余没动。相邻两年的节气日期几乎一模一样,今年立春 2 月 4 日,明年大概率还是 2 月 4 日。四十八位里真正变化的往往只有两三位。下一章处理的就是这份年度间冗余。
4. 位流与差分编码:把相邻年份的重复部分真正清零
4.1 年度差分与稀疏度,压缩率的真正来源
按年切分后直接存储,农历每年十七位、节气每年四十八位,八百年合计约六点五 KB。这个体积已经可用,但离「极限」还很远,因为这些记录彼此并不独立。
先说农历。相邻两年的月长序列高度相似,因为二十九点五三天的月长决定了大小月模式基本按照一个近似周期的节奏摆动。把相邻两年的十七位整数做异或,结果里置一的位数在多数年份只有三到五位。这意味着十七位里平均有十二位以上是「去年什么样,今年还什么样」。
节气更明显。二十四节气在公历里是准固定的,连续两年完全相同的情况很常见,异或结果经常只有两三位甚至零位。
稀疏性只有配上变长编码才能换成空间。做法是:每条记录先算它与前一条的异或值,再用变长整数写入位流——前面有多少个零就用少量位表示,真正非零的部分另存。
def zigzag(n: int) -> int: """把有符号整数映射到无符号,小的负数也能得到小的编码值""" return (n << 1) ^ (n >> 63) def write_varint(writer, value: int): """LEB128 变长写入:每字节低 7 位是数据,最高位表示是否还有后续""" while True: byte = value & 0x7F value >>= 7 if value: writer.write_byte(byte | 0x80) else: writer.write_byte(byte) returnLEB128 对小数值极友好:小于 128 的值只占一个字节。异或结果里非零位的分布位置不固定,但「置一位数」这个统计量很小,所以更好的方式是先记录置一位数,再只写那些位的位置。
def encode_delta_xor(prev: int, cur: int, writer): """差分 + 稀疏位置编码:只记录翻转了哪几位""" diff = prev ^ cur positions = [i for i in range(diff.bit_length()) if (diff >> i) & 1] write_varint(writer, len(positions)) last = 0 for p in positions: write_varint(writer, p - last) # 位置本身也做差分,进一步缩小数值 last = p这里用了两级差分:先对记录值差分,再对置一位的位置差分。位置列表是递增的,做差之后每个增量都是小数,LEB128 基本一字节一个。翻转五位的话,整条记录约七字节,比原生的十七位(约二点一字节)反而更大了。
这就是差分编码真正的边界条件:当原始记录本身就很短,稀疏编码的元数据开销会吃掉收益。十七位的农历年记录,异或后只剩三到五位置一,用「位数 + 位置」表达反而超过直接存。节气的四十八位记录才能从稀疏编码中获益。
所以实际可用的方案是按字段分别处理,而不是套一个统一编码器。农历的十七位直接按位流顺序拼接,节气记录走差分稀疏路径。混合编码器的选择依据是「原始宽度 ÷ 平均翻转位数」这个比值,低于某个阈值就别上差分。
4.2 位流读写器,为什么不能按字节对齐
十七位、四十八位这些宽度都不是八的倍数,直接按字节存会浪费。按年存储的方案里,每年浪费七位,八百年就是五千六百位,七百字节。这部分必须靠位流省下来。
class BitWriter: def __init__(self): self.buf = bytearray() self.acc = 0 # 待写出的位累加器 self.nbits = 0 # 累加器里已有多少位 def write(self, value: int, width: int): """把 value 的低 width 位追加到流里,低位先出""" value &= (1 << width) - 1 self.acc |= value << self.nbits self.nbits += width while self.nbits >= 8: self.buf.append(self.acc & 0xFF) self.acc >>= 8 self.nbits -= 8 def finish(self) -> bytes: if self.nbits: self.buf.append(self.acc & 0xFF) # 尾部补零凑满一字节 return bytes(self.buf) class BitReader: def __init__(self, data: bytes): self.data = data self.pos = 0 # 当前字节下标 self.acc = 0 self.nbits = 0 def read(self, width: int) -> int: while self.nbits < width: self.acc |= self.data[self.pos] << self.nbits self.pos += 1 self.nbits += 8 value = self.acc & ((1 << width) - 1) self.acc >>= width self.nbits -= width return value低位先出的顺序不是随便选的。解包时读十三个位,如果高位先出,每次读都要先算当前位在哪个字节的第几位,再做掩码和移位两步;低位先出则可以先把整个字节塞进累加器,取出时只做一次与和一次右移。这个差异在单次解析里微不足道,但在遍历几百年数据时会被放大成可测量的差别。
累加器的宽度也要注意。Python 的整数没有溢出问题,但同一段逻辑用 C 实现时,acc必须能装下「当前剩余位数 + 一次写入的最大宽度」,用 64 位整数时单次写入宽度不能超过 57 位。四十八位的节气记录正好在这个边界内。
4.3 随机访问索引,压缩之后仍然要 O(1) 查年
差分加位流的组合有个致命缺点:要读第 n 年,必须先解出前面所有年。这在顺序遍历时没问题,但日历应用最常见的操作就是「用户跳到 2050 年看春节是哪天」,从头解几千条记录完全不可接受。
解法是分块加索引。每六十四年切一块,块头记录该块起始的字节偏移,以及该块第一年的农历正月初一儒略日数和节气基准。查第 n 年时先跳到所在块的起点,块内最多解六十三条记录。
BLOCK_YEARS = 64 INDEX_ENTRY = 12 # 4 字节偏移 + 8 字节基准儒略日数 def build_index(encoded_blocks: list[bytes], base_jdns: list[int]) -> bytes: """生成块索引:每条记录 12 字节""" out = bytearray() offset = 0 for i, blk in enumerate(encoded_blocks): out += offset.to_bytes(4, "little") out += base_jdns[i].to_bytes(8, "little") offset += len(blk) return bytes(out)六十四年是个需要权衡出来的值。块太小,索引条目多,索引本身的开销上来了;块太大,块内顺序解析的代价又回到不可忽视的量级。六十四在两者之间,索引开销约占总体的百分之二,块内解析最多六十次位读取,对交互式查询完全够用。
把农历和节气的分块交叉存储还是分开存储,也是一个选择。分开存储更简单,两类记录宽度和编码方式都不同,混在一起会让位流读写逻辑变复杂;交叉存储则在同时需要两类数据的场景(比如生成一整年的日历页)下减少一次寻道。多数实现选前者,因为日历应用查询「某天是农历几月初几」和「某天是哪个节气」是两个独立需求,很少同时发生。
5. 避坑与排查:压缩表最容易翻车的五个地方
5.1 用 19 年 7 闰反推闰月,误差在三百年后爆发
现象:拿一张压缩表去核对远期的闰月,发现某一年开始整体错位,而且错得很有规律——每年春节都晚一天左右。原因是用 19 年 7 闰的周期规律去「补全」表里稀疏的年份,或者用来校验表是否正确。19 个回归年与 235 个朔望月差 0.09 天,三百三十多年累积一天,之后所有的闰月判断都会向前或向后滑一个月。解决方式是彻底放弃用周期推闰月,所有闰月位置必须来自压缩表本身,校验也只能用表外的独立数据源交叉比对,不能用同一个规律自证。
5.2 位流没有按记录边界对齐,尾部补零吃掉了下一条
现象:解包第 n 年时数据正确,第 n+1 年开始出现乱值,而且乱的位置每次都不同。原因是写入方在每条记录末尾调用了一次finish(),把不足八位的部分补零凑整,读取方却按连续位流读取,于是每条记录都多吃了几位零。解决方式是位流只在整表末尾补一次零,中间记录一律直接连续写入;或者干脆给每条记录加一个「记录起始位偏移」的小索引,出错时能立刻定位是哪一条开始偏的。
5.3 农历链条式推演让单点错误静默扩散
现象:压缩表里某一年的一条记录被改错一位,那一年的春节日期错了,但更麻烦的是之后所有年份的春节都错了,而且错的幅度只有一到两天,看起来像正常浮动,人工抽查很难发现。原因是正月初一走的是累加链条,第 n 年依赖前面全部年。解决方式有三层:写入时在每块块头固化该块首年的正月初一儒略日数,让错误最多污染一个块;解包时校验每年总天数是否落在 353 到 355 或 383 到 385 这六个数里;再对若干已知年份做回归断言。
5.4 节气偏移越界时静默截断,日期整体漂移
现象:某些年份的节气日期比正确值早或晚了一天,集中在立春、冬至这几个浮动范围大的节气上。原因是用 2 位存偏移时,把超范围的 -2 或 +3 直接做了掩码运算,-2 & 0x03得到 2,被解读成 +1,偏移量凭空多了三天。解决方式是打包时显式断言范围,越界就落到例外表而不是靠位运算自然截断;解包时对例外位置做二次覆盖,并且给例外表本身加计数校验。
5.5 差分编码用于短记录,压缩率反而倒退
现象:把农历的十七位记录也接上差分稀疏编码后,压缩后的体积比直接按位流存还大了一成多。原因是稀疏编码要先写「翻转了几位」再写每一位的位置,十七位记录翻转三到五位时,元数据开销已经超过原始宽度。解决方式是按字段分别评估,只有原始宽度明显大于平均翻转位数的字段才走差分路径;判断依据可以在打包工具里直接统计,不用靠猜。
6. 数据提取工具的设计取舍:压缩表的价值在于可验证,不在于更小
压缩算法本身写完只是做完了一半。真正决定这套东西能不能长期用的是配套的提取工具——它负责把原始历算数据变成压缩表,而它最容易做错的地方,是把自己做成了一个只搬运数据的脚本。
一个只搬运数据的工具,输入一份数据,输出一份二进制,中间没有任何断言。表生成出来,谁也不知道对不对,要等半年后某个用户发现春节日期错了才知道。可验证的提取工具做法完全不同:它在转换的每一步都留下可回放的证据。常见的实现思路是三段式——先解析原始数据成中间结构体,再从中间结构体打包成压缩表,最后从压缩表反解回中间结构体,逐字段比对。第三步是很多人省掉的,恰恰是最有用的一步,因为打包和解包是两套独立代码路径,只有它们对不上才说明有一边写错了。
第二层防线是外部锚点。压缩表里挑出一批关键日期——每年春节的公历日期、冬至的日期、若干个闰月的月份——这些值在生成时额外写进一份独立的校验清单,不参与压缩,只用于回归。每次改压缩格式或者换数据源,跑一遍清单比对,任何一位偏差都会立刻暴露。清单本身要足够小,小到可以人工抽查几条,又要足够分散,覆盖基准年附近、表中段和表尾。
第三层是边界测试。压缩表的头尾两侧是最容易出错的地方:第一年的正月初一没有前一年可依赖,必须来自表头的绝对基准;最后一年如果位流尾部补零处理不当,会解出多余记录。这两处都应该有专门的用例,而不是靠「跑一遍没报错」来判断。
取舍在这里就浮出来了。压缩率、可读性、可维护性三者不可能同时最大化。把表压到极致,位宽全部按众数设计、差分用得干干净净,代价是任何人拿到这份二进制都无法用肉眼看懂,出问题时只能靠工具反解;反过来,用每年一个结构体存,可读性满分,体积却是前者的几十倍,塞不进小容量固件。
我的判断是分场景。目标平台存储宽裕(几十兆起步的应用),优先可读性和可维护性,压缩表的价值主要在于减少解析时间和内存占用,不在于省那几个字节;目标平台是嵌入式、离线设备、需要在几百 KB 里放下整个历法模块时,才值得上极限压缩,而且必须把提取工具和校验清单当成交付物的一部分,一起打包。压缩本身只是手段,能证明它没压错才是这套设计的真正产出。
所以这份资源里真正值得先看的是提取工具和校验清单——它们是判断压缩表可信度的依据,算法细节是第二位的。
吃过一次亏之后,我到现在改任何压缩格式,都会先把「反解比对」这一步跑通再动手写打包逻辑。顺序反过来的话,后面每改一次位布局,都要重新怀疑一遍到底是布局错了还是解包错了。希望帮到你。
本文还有配套的精品资源,点击获取