拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从“渔夫打鱼晒网”到周期调度:日期计算与状态机的编程实践

1. 项目概述从“渔夫打鱼晒网”到程序化思维“三天打鱼两天晒网”这句我们从小听到大的俗语背后其实藏着一个非常经典的编程逻辑问题。我第一次接触“渔夫打鱼晒网”这个题目还是在大学C语言课上当时觉得这不过是个简单的日期计算和取模运算。但后来在实际工作中尤其是在处理周期性任务调度、会员权益计算、设备维护周期规划时我才猛然发现这个看似简单的模型其内核逻辑无处不在。它本质上是一个日期驱动的状态机模型核心是给定一个起始点和一个固定的周期规律判断任意一天处于这个周期中的哪个状态。简单来说这个问题就是假设渔夫从某年某月某日开始“三天打鱼两天晒网”的周期循环我们需要编写一个程序输入任意一个日期程序能自动判断出这一天渔夫是在“打鱼”还是在“晒网”。这听起来是不是特别像某些会员系统的“权益日”判断或者像某个自动化设备“运行N天检修M天”的排期没错其应用场景远不止于一个编程练习题。今天我就从一个老程序员的角度带大家彻底拆解这个问题。我们不止步于写出能跑的代码更要深挖其背后的日期处理核心、多种解法的优劣对比、边界情况的“坑”以及如何将它优雅地应用到更复杂的实际场景中。无论你是正在学习编程基础的新手还是需要处理类似业务逻辑的开发者相信这篇深度剖析都能给你带来不一样的收获。2. 问题核心与数学模型建立2.1 问题重述与抽象化首先让我们把生活化的描述转化为精确的技术定义。已知条件起始日期渔夫开始周期的具体日期例如2023年1月1日。周期规则“三天打鱼两天晒网”。这意味着一个完整的周期长度为 3 2 5 天。在一个周期内前3天状态为“打鱼”后2天状态为“晒网”。目标日期需要判断状态的任意日期例如2025年5月20日。求解目标判断“目标日期”相对于“起始日期”在经历了若干个完整周期后位于当前周期的第几天从而确定其状态是“打鱼”还是“晒网”。关键抽象状态打鱼 (Fishing) 或 晒网 (Resting)。周期一个长度为5的固定循环序列[Fishing, Fishing, Fishing, Resting, Resting]。核心计算计算从起始日到目标日所经过的总天数。2.2 数学建模关键在于“总天数”与“取模运算”一旦我们计算出总天数记为totalDays问题就简化为了一个纯粹的数学问题。计算总天数totalDays 目标日期 - 起始日期 1。这里的“1”是因为起始日当天是第1天。例如从1月1日到1月1日经过天数是1天。确定周期位置用总天数除以周期长度5得到余数。remainder totalDays % 5根据余数判断状态如果remainder等于 1, 2, 3状态为“打鱼”。如果remainder等于 4, 0状态为“晒网”。注意当余数为0时代表正好处于一个周期的最后一天即第5天是晒网。注意这里有一个初学者极易混淆的点余数remainder与周期中的第几天position的对应关系。remainder的取值范围是 0~4而一个周期中的位置是 1~5。所以remainder为0对应位置5晒网remainder为1对应位置1打鱼以此类推。在代码中清晰定义这个映射关系至关重要。至此复杂的日期问题被转化为了两个更基础的子问题如何准确计算两个日期之间的天数差以及如何用代码优雅地实现上述逻辑接下来我们就重点攻克第一个也是最容易出错的难点。3. 核心难点攻坚精确计算日期差计算两个日期间的天数差是这个问题真正的“技术心脏”。方法有很多选择取决于你的编程语言和场景需求。3.1 方法一暴力累加法理解原理不推荐实战这是最直观但效率最低的方法。思路是从起始日期开始一天一天加到目标日期同时计数。你需要自己处理闰年、月份天数切换。def is_leap_year(year): 判断闰年 return (year % 4 0 and year % 100 ! 0) or (year % 400 0) def days_in_month(year, month): 获取某年某月的天数 month_days [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] if month 2 and is_leap_year(year): return 29 else: return month_days[month - 1] def brute_force_days_diff(start_year, start_month, start_day, end_year, end_month, end_day): 暴力累加计算天数差含起始日 days_count 0 y, m, d start_year, start_month, start_day while (y, m, d) (end_year, end_month, end_day): days_count 1 d 1 if d days_in_month(y, m): d 1 m 1 if m 12: m 1 y 1 return days_count为什么不推荐效率是O(n)n是天数。如果日期跨度几十年循环次数可能上万甚至更多在性能上不可接受。但它对于理解日期流转的过程非常有帮助。3.2 方法二基于“儒略日”或“纪元时间戳”的计算推荐实战这是现代编程中最通用、最可靠的方法。核心思想是将日期转换为一个从某个固定起点如公元1年1月1日或1970年1月1日开始连续递增的整数然后做减法。以“从公元1年1月1日开始的累计天数”为例我们可以编写一个函数将任意日期换算成这个“绝对天数”。def to_absolute_days(year, month, day): 将日期转换为从公元1年1月1日开始的天数简化版忽略历法变更 # 计算年份贡献的天数考虑闰年 days (year - 1) * 365 (year - 1) // 4 - (year - 1) // 100 (year - 1) // 400 # 计算月份贡献的天数 month_days [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] # 索引0占位 if is_leap_year(year): month_days[2] 29 days sum(month_days[:month]) # 前几个月的总天数 # 加上当月的天数 days day return days def efficient_days_diff(start_date, end_date): 高效计算天数差含起始日 start_abs to_absolute_days(*start_date) end_abs to_absolute_days(*end_date) return end_abs - start_abs 1 # 1 因为包含起始日这种方法的优势效率极高无论日期跨度多大计算都是O(1)复杂度只有几次算术运算。准确可靠正确处理了闰年规则格里高利历四年一闰百年不闰四百年再闰。思路通用这个“转换为绝对参照系后做差”的思想在计算时间间隔、日期比较时非常普遍。3.3 方法三利用现代编程语言的标准库最简实践对于大多数实际开发我们根本不需要自己造轮子。直接使用语言内置的日期时间库是最佳选择。from datetime import datetime def library_days_diff(start_str, end_str): 使用datetime库计算天数差 fmt %Y-%m-%d start datetime.strptime(start_str, fmt) end datetime.strptime(end_str, fmt) # datetime相减得到timedelta对象 delta end - start # delta.days 是间隔天数不包含结束日注意 # 根据我们的定义包含起始日总天数 delta.days 1 total_days delta.days 1 return total_days实操心得使用标准库时务必仔细阅读文档搞清楚“天数差”的定义。例如Python的timedelta.days表示两个date对象之间相差的天数(end - start).days的结果如果end和start是同一天则为0。这与我们模型中“从起始日到起始日算1天”的定义不同所以需要1进行校正。这是一个非常典型的边界细节测试时一定要用起始日等于目标日这个用例来验证。4. 完整解决方案与代码实现掌握了核心的日期差计算我们就可以组装完整的解决方案了。这里我将提供两个版本的实现一个用于理解原理的“教学版”和一个注重健壮性和实用性的“工程版”。4.1 教学版逐步拆解清晰注释这个版本将计算总天数和状态判断分开逻辑一目了然。# 渔夫打鱼晒网问题 - 教学版 from datetime import datetime def calculate_total_days(start_date_str, query_date_str): 计算从起始日到查询日所经过的总天数包含起始日。 参数格式YYYY-MM-DD date_format %Y-%m-%d start datetime.strptime(start_date_str, date_format) query datetime.strptime(query_date_str, date_format) # 计算日期差timedelta的days属性是整数值 delta query - start # 注意delta.days 可能是负数如果查询日期早于起始日期 # 根据问题定义我们假设查询日期不早于起始日期否则无意义。 if delta.days 0: raise ValueError(查询日期不能早于起始日期。) # 总天数 间隔天数 1 (因为起始日算第一天) total_days delta.days 1 return total_days def judge_fisherman_status(total_days): 根据总天数判断状态。 cycle_length 5 remainder total_days % cycle_length # 余数映射1,2,3 - 打鱼 0,4 - 晒网 if remainder in [1, 2, 3]: return 打鱼 else: # remainder 为 0 或 4 return 晒网 def main_teaching(): 主函数 start_date 2023-01-01 query_date 2025-05-20 try: total calculate_total_days(start_date, query_date) status judge_fisherman_status(total) print(f从 {start_date} 到 {query_date}共经过 {total} 天。) print(f这一天渔夫的状态是{status}) except ValueError as e: print(f输入错误{e}) if __name__ __main__: main_teaching()4.2 工程版面向对象健壮实用在实际项目中我们可能需要多次查询不同日期或者起始日期是固定的。面向对象的设计和更完善的错误处理会更有优势。# 渔夫打鱼晒网问题 - 工程版 from datetime import datetime, date from typing import Optional class FishermanSchedule: 渔夫作息调度器。 封装了起始日期和周期逻辑便于重复查询。 CYCLE_LENGTH 5 FISHING_DAYS {1, 2, 3} # 周期内打鱼的日子位置 def __init__(self, start_date_str: str): 初始化调度器。 :param start_date_str: 起始日期字符串格式 YYYY-MM-DD self.start_date self._parse_date(start_date_str) self._validate_date(self.start_date) staticmethod def _parse_date(date_str: str) - date: 解析日期字符串支持多种常见格式 for fmt in (%Y-%m-%d, %Y/%m/%d, %Y%m%d): try: return datetime.strptime(date_str, fmt).date() except ValueError: continue raise ValueError(f无法解析日期字符串{date_str}。请使用类似 2023-01-01 的格式。) staticmethod def _validate_date(d: date): 简单的日期验证示例不能早于1900年 if d.year 1900: raise ValueError(起始日期不建议早于1900年以避免历法计算复杂性。) def get_status(self, query_date_str: str) - str: 查询指定日期的状态。 :param query_date_str: 查询日期字符串 :return: 打鱼 或 晒网 query_date self._parse_date(query_date_str) total_days self._days_between(self.start_date, query_date) if total_days 0: raise ValueError(f查询日期({query_date_str})必须不早于起始日期({self.start_date})。) # 计算在周期中的位置 (1-based) position_in_cycle (total_days - 1) % self.CYCLE_LENGTH 1 if position_in_cycle in self.FISHING_DAYS: return 打鱼 else: return 晒网 def _days_between(self, start: date, end: date) - int: 计算两个日期之间的天数包含起始日。 使用date对象直接相减逻辑更清晰。 if end start: return -1 # 或者直接抛异常这里返回-1供内部判断 # (end - start).days 是间隔的天数差 return (end - start).days 1 def get_schedule_for_range(self, start_query: str, end_query: str) - dict: 获取一个日期范围内的状态表用于批量查询或生成日历 start_d self._parse_date(start_query) end_d self._parse_date(end_query) if end_d start_d: start_d, end_d end_d, start_d # 自动交换保证开始早于结束 schedule {} current start_d one_day timedelta(days1) while current end_d: schedule[current.isoformat()] self.get_status(current.isoformat()) current one_day return schedule # 使用示例 if __name__ __main__: # 创建一个从2023年元旦开始的渔夫日程 scheduler FishermanSchedule(2023-01-01) # 单日查询 test_date 2023-01-15 status scheduler.get_status(test_date) print(f{test_date}: {status}) # 批量查询接下来一周 from datetime import timedelta base_date datetime.strptime(2023-01-01, %Y-%m-%d).date() for i in range(10): query_d base_date timedelta(daysi) s scheduler.get_status(query_d.isoformat()) print(f{query_d.isoformat()}: {s})工程版的优势封装性将数据和逻辑封装在类中使用更简洁。健壮性增加了日期格式解析、基础验证、错误处理。扩展性很容易添加新功能如批量查询(get_schedule_for_range)、修改周期规则只需修改类常量。清晰性使用position_in_cycle1-5的概念比直接使用余数0-4更符合直觉代码可读性更高。5. 边界情况、常见陷阱与测试策略即使逻辑正确如果没有充分考虑边界情况程序也会在关键时刻“掉链子”。下面是我在多年开发中总结的关于此类日期计算问题的“坑点”清单。5.1 必须处理的边界情况起始日等于查询日这是最基本的测试用例。总天数应为1处于周期第1天状态应为“打鱼”。很多自己实现的日期差函数容易在这里出错返回0。跨闰年2月29日这是检验日期计算是否正确的“试金石”。如果你的算法不能正确处理2020年2月29日这样的日子那就是错误的。务必用包含闰年2月29日的日期段进行测试。例如起始日 2020-02-28查询日 2020-03-01总天数应为328, 29, 1。查询日期早于起始日期业务上可能无意义但程序必须能处理给出明确的错误提示而不是输出一个莫名其妙的结果。非常大的日期跨度测试起始日期很早如1900-01-01查询日期很晚如2100-12-31。这能测试算法的效率和整数溢出问题在C/JAVA等语言中需注意。Python的整数大数无忧但自己实现的累加算法会极慢。非法日期输入如2023-02-30、2023-13-01、abcd-ef-gh。程序应该能捕获这些错误而不是崩溃或产生垃圾结果。5.2 常见逻辑错误余数映射错误这是最高发的错误。混淆了“余数”和“周期位置”。错误if total_days % 5 3: # 打鱼。当total_days是5的倍数时余数为0条件成立误判为打鱼。正确应明确处理余数为0的情况或统一转换为1-based的位置再判断。天数差计算是否“1”这是另一个高频错误点。关键在于你对“总天数”的定义。在我们的模型中起始日当天是第1天所以必须1。一定要在代码注释和函数命名中明确这一点例如函数名days_between_inclusive。月份和年份进位逻辑错误在自实现日期累加时if (day days_of_month[month])这样的判断顺序和边界条件很容易写错特别是涉及到2月和闰年时。5.3 系统化的测试策略不要只测一两个日子。建立一个测试套件是专业性的体现。import unittest from datetime import date, timedelta class TestFishermanSchedule(unittest.TestCase): def setUp(self): self.scheduler FishermanSchedule(2023-01-01) def test_same_day(self): 测试起始日当天 self.assertEqual(self.scheduler.get_status(2023-01-01), 打鱼) def test_first_cycle(self): 测试第一个完整周期 expected { 2023-01-01: 打鱼, # day1 2023-01-02: 打鱼, # day2 2023-01-03: 打鱼, # day3 2023-01-04: 晒网, # day4 2023-01-05: 晒网, # day5 2023-01-06: 打鱼, # day6 新周期开始 } for d, s in expected.items(): with self.subTest(dated): self.assertEqual(self.scheduler.get_status(d), s) def test_leap_year(self): 测试跨越闰年2月29日 scheduler_leap FishermanSchedule(2020-02-28) self.assertEqual(scheduler_leap.get_status(2020-02-28), 打鱼) # day1 self.assertEqual(scheduler_leap.get_status(2020-02-29), 打鱼) # day2 self.assertEqual(scheduler_leap.get_status(2020-03-01), 打鱼) # day3 def test_invalid_date(self): 测试非法日期 with self.assertRaises(ValueError): self.scheduler.get_status(2023-02-30) def test_date_earlier_than_start(self): 测试早于起始日的日期 with self.assertRaises(ValueError): self.scheduler.get_status(2022-12-31) if __name__ __main__: unittest.main()编写这样的单元测试不仅能确保当前代码正确未来修改代码比如优化日期计算函数时也能快速验证是否引入了回归错误。6. 从习题到实战应用场景拓展“渔夫打鱼晒网”模型绝不仅仅是一道编程题。理解其本质后你可以在很多业务场景中看到它的影子。6.1 场景一周期性任务调度与状态管理假设你有一个自动化运维脚本需要每5天执行一次全量备份备份日备份后需要2天时间进行数据校验和归档校验日然后进入下一个周期。周期7天5天备份 2天校验。状态备份中、校验中。问题给定任意一天系统处于何种状态今天是否需要触发备份任务你可以直接复用渔夫问题的核心逻辑只需修改周期长度和状态映射。class BackupScheduler: CYCLE_LENGTH 7 BACKUP_DAYS {1, 2, 3, 4, 5} # 周期内第1-5天备份 VERIFY_DAYS {6, 7} # 第6-7天校验 def __init__(self, first_backup_day): self.start_date parse_date(first_backup_day) def get_today_status(self): today date.today() total_days days_between(self.start_date, today) position (total_days - 1) % self.CYCLE_LENGTH 1 if position in self.BACKUP_DAYS: return {status: BACKUP, need_trigger: position 1} # 仅周期第一天触发 else: return {status: VERIFY, need_trigger: False}6.2 场景二会员权益或周期性活动计算很多电商或服务类应用有“会员周”概念。例如会员每周前3天享受折扣后4天恢复原价。或者一个促销活动以7天为一个周期循环。问题用户在今天下单应该享受什么价格解法将活动开始日期设为起始日周期为7天判断今天在周期中的位置即可确定价格系数。6.3 场景三设备维护与排班计划工厂里的设备A运行8天必须停机维护2天。多个设备交替运行以保证生产线不停。问题给定今天是2024年1月1日设备A从2023年12月1日开始运行今天它应该运行还是维护解法这就是一个标准的“10天周期”8运行2维护的渔夫问题。可以快速计算出设备状态用于生成工单或报警。6.4 场景四更复杂的循环规则渔夫问题是“固定周期内的固定顺序”。现实可能更复杂比如“工作5天休息2天周末但若遇到法定假日则顺延”。这就不再是简单的周期函数需要引入日历规则。但“渔夫问题”仍然是其最核心的底层模型复杂的规则是在此基础上增加的“例外处理”层。实操心得当遇到复杂排班规则时我通常采用“基础周期 例外日历”的策略。先按渔夫模型算出基础状态再用一个“例外日期表”去覆盖特定日期的状态。这样逻辑清晰易于维护和修改。7. 算法优化与进阶思考对于最基本的渔夫问题上述解法已足够。但如果追求极致性能或者在资源受限的环境下如嵌入式设备我们可以进行一些优化。7.1 优化点避免重复计算绝对天数在工程版的FishermanSchedule类中每次查询都要计算一次从公元元年到目标日的绝对天数实际上做了大量重复计算。我们可以预先计算起始日的绝对天数之后查询时只计算目标日的绝对天数然后相减。class OptimizedFishermanSchedule: def __init__(self, start_date_str): self.start_abs self._date_to_absolute(start_date_str) def _date_to_absolute(self, date_str): 优化版一次性将日期转换为优化后的整数表示 y, m, d map(int, date_str.split(-))) # 使用更高效的日期转换算法如Zellers congruence或预计算表 # 此处为示例仍使用清晰但非最优的算法 days d for month in range(1, m): days self._days_in_month(y, month) days (y-1)*365 (y-1)//4 - (y-1)//100 (y-1)//400 return days def get_status_fast(self, query_date_str): query_abs self._date_to_absolute(query_date_str) total_days query_abs - self.start_abs 1 # ... 后续判断逻辑不变对于需要每秒处理成千上万次查询的服务这种优化能显著降低CPU开销。7.2 进阶思考如何处理“非固定起始点”和“周期重置”有时周期不是从某个固定日期开始无限循环的。例如渔夫可能从第一次出海开始算周期但中间因为天气原因连续休息了10天之后周期要重新从“打鱼”开始计算。这就引入了“状态中断与重置”的概念。解决这类问题需要引入一个“事件日志”或“状态历史”。核心算法不再是简单的日期计算而变成了记录每个“周期开始”的日期。当发生“中断”事件时记录中断日期和恢复日期。查询某一天的状态时需要找到距离该日期最近的一个“有效周期开始点”并考虑中间的所有中断区间。这实际上是一个简化版的日程安排Scheduling或日历Calendar系统问题。数据库设计上可能需要events表包含start_date,end_date,event_type如‘FISHING_CYCLE_START‘, ’STORM_BREAK‘等字段。从“渔夫打鱼晒网”这个简单问题出发我们一路深入触及了日期计算、状态机、周期调度、边界处理、单元测试、对象建模乃至更复杂的排班系统设计。这正是编程的魅力所在一个简单的问题模型是通往理解更复杂系统的桥梁。下次当你再看到任何带有周期性规律的业务需求时不妨先想想这是不是又一个“渔夫”在等着你用清晰、健壮的代码去安排他的作息呢记住把复杂问题分解并找到其背后的简单模型是程序员最重要的能力之一。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门