LeetCode 1360. 日期之间隔几天
题目描述
题意分析
给两个格式为
YYYY-MM-DD的日期字符串date1和date2,返回它们之间相隔的天数。
题面有三条约束直接决定了写法。第一,日期格式严格固定为四位年、两位月、两位日,中间用
-分隔,总长永远是 10 个字符,因此可以按固定下标切片解析,完全不需要任何分隔符扫描或正则。第二,日期范围是1971-01-01到2100-12-31,跨度只有 130 年,这意味着「逐年累加」这种线性做法的代价可以忽略,同时也意味着不会遇到 1582 年格里高利历改历那种历史遗留问题。第三,题目不保证date1早于date2,两个日期可以是任意先后顺序,甚至可以相同。
从「不保证先后」这条约束可以直接读出解法的形状:如果能给每个日期算出一个单调的整数编号,那么答案就是两个编号之差的绝对值,先后顺序自动被绝对值抹平,不需要写任何比较分支去交换两个日期。这比「从早的日期一天天推到晚的日期」要干净得多。
真正需要小心的只有闰年。上界 2100 恰好是一个「能被 4 整除但不能被 100 整除也不能被 400 整除」的年份,也就是平年。题目把范围定到 2100 不是随手写的,它就是在专门检验闰年规则有没有写全——只写
year % 4 == 0的实现会在 2100 年 3 月及以后的日期上多算一天,而这恰好是题目值域里唯一能暴露这个 bug 的地方。
边界还有两处。两个日期完全相同时答案是 0,天数编号相减自然得到 0,不需要特判。同一年内、甚至同一月内的两个日期,整年循环和整月循环都不会执行,答案由日号相减得出,也不需要特判。
解法:日期转天数序号
核心思路
最直接的暴力是模拟:从较早的日期出发,一天一天往后推,每推一天就要判断当前月是否到头(还要看是不是 2 月、是不是闰年),到头则月加一,月到 13 则年加一月归一,直到追上较晚的日期。这个做法能过,但它把「月末进位」和「闰年」这两件麻烦事塞进了循环体,分支多、边界密,白板上极容易写错,而且还得先比较两个日期的先后。
瓶颈在于「相对距离」被表达成了一个过程。换个角度:日期是一维全序的,只要能找到一个严格单调的映射 $f$,把日期映成整数,且相邻两天的编号恰好相差 1,那么任意两个日期的间隔天数就是 $ f(d_1) - f(d_2) $。这类「把二维/三维结构压成一维序号,再用序号做差」的思路,在时间、坐标、进制这几类题里反复出现。
参考点取题目下界
1971-01-01。定义 $f(y, m, d)$ = 从 1971 年到第 $y-1$ 年的整年天数之和 + 当年前 $m-1$ 个整月的天数之和 + $d$。这个定义下 $f(1971, 1, 1) = 1$,每往后一天编号恰好加一,单调性显然。注意参考点选谁其实无所谓——只要两个日期用同一个参考点,公共的偏移量在相减时会被抵消掉,这也是为什么不必纠结f的绝对值是不是「真实的」天数。
三段累加各自回答一个问题。整年部分:第 $y$ 年有 366 天当且仅当 $y$ 是闰年,否则 365 天,循环
for y in [1971, year)。整月部分:用一张固定的平年月长表{31,28,31,30,31,30,31,31,30,31,30,31},循环for m in [1, month)累加;如果当前日期所在的年是闰年,且累加跨过了 2 月,则额外补一天。日号部分:直接加day。
闰年判定
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)的三条规则缺一不可:四年一闰、百年不闰、四百年再闰。这里必须强调的是,本题的考点就是手写这套日期算术。用LocalDate.parse(...).toEpochDay()或者time.Parse两行就能过,但那等于把题目要考的东西整个交给了标准库,面试里这样写基本等同于放弃作答。
解题步骤
- 按固定下标解析字符串:
date.substring(0, 4)取年、substring(5, 7)取月、substring(8, 10)取日。之所以能这么写,是因为格式被题目锁死为定长;下标要跳过位置 4 和位置 7 上的两个-。Go 版本里额外手写了一个atoi,用num = num*10 + int(s[i]-'0')逐位累积——字符减'0'得到数字值,这是所有手写解析的基础。
- 累加整年天数:
for (int y = 1971; y < year; y++),闰年加 366 否则加 365。循环上界是year的前一年(用<而非<=),因为当年的天数要由后面的月份和日号来细分,写成<=会把当前这一整年重复计入。
- 累加整月天数:
for (int m = 1; m < month; m++),同样用<而非<=,因为当前这个月只走到day号,不能按整月计。表下标是m - 1,因为月份从 1 开始而数组从 0 开始。
- 补闰年的 2 月:在月循环里判断
m == 2 && isLeapYear(year)时额外加一天。注意这里用的是日期本身所在的年份year,不是外层整年循环的y;而且判断必须放在月循环里,只有当month > 2(即累加真的跨过了 2 月)时才会被执行到——1 月和 2 月的日期不该被补这一天。
- 加上日号并返回:
answer + day。这一步让1971-01-01得到编号 1 而不是 0,是不是从 1 开始并不影响答案,因为两个编号相减时这个常数偏移会抵消。
- 取绝对差:Java 用
Math.abs(days1 - days2);Go 没有整数版abs,直接写一个大小比较分支返回正差值。这一步就是「不保证先后顺序」这条约束的全部代价。
以
date1 = "2020-01-15"、date2 = "2019-12-31"走一遍(正确答案 15)。
先算 $f(2019, 12, 31)$。整年部分累加 1971 到 2018 共 48 年,其中闰年是 1972、1976、……、2016 共 12 个,所以整年天数 $= 48 \times 365 + 12 = 17520 + 12 = 17532$。整月部分累加 1 到 11 月,全年 365 减去 12 月的 31 天得 334;2019 不是闰年,所以 2 月不补天,得 $17532 + 334 = 17866$。最后加日号 31,得 $f = 17897$。
再算 $f(2020, 1, 15)$。整年部分比上面多了 2019 一年,2019 是平年,所以 $17532 + 365 = 17897$。月份是 1 月,整月循环一次都不进,不加任何东西。最后加日号 15,得 $f = 17912$。
两者相减 $17912 - 17897 = 15$,取绝对值仍是 15,与答案一致。注意这一组用例跨了年,如果整年循环误写成
y <= year,f(2019,12,31)会把 2019 整年再算一遍,结果直接偏掉 365 天。
再看一组专门打闰年规则的用例:
"2100-02-28"与"2100-03-01"。正确答案是 1,因为 2100 年能被 4 整除但也能被 100 整除、且不能被 400 整除,是平年,2 月只有 28 天。若闰年函数只写year % 4 == 0,f(2100, 3, 1)的月循环会在m == 2时多补一天,算出的差变成 2。这就是题目把上界定在 2100 的原因。
代码实现
class Solution {
public int daysBetweenDates(String date1, String date2) {
int days1 = countDays(date1);
int days2 = countDays(date2);
// 题目不保证 date1 早于 date2,取绝对值即可省掉先后比较。
return Math.abs(days1 - days2);
}
private int countDays(String date) {
// 格式被题目锁定为定长 YYYY-MM-DD,可以按固定下标切片,跳过位置 4 和 7 的分隔符。
int year = Integer.parseInt(date.substring(0, 4));
int month = Integer.parseInt(date.substring(5, 7));
int day = Integer.parseInt(date.substring(8, 10));
int[] monthDays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int answer = 0;
// 只累加到 year 的前一年,当年天数留给月份和日号细分。
for (int y = 1971; y < year; y++) {
if (isLeapYear(y)) {
answer += 366;
} else {
answer += 365;
}
}
// 同理只累加到 month 的前一月;跨过 2 月且当年闰年时补一天。
for (int m = 1; m < month; m++) {
answer += monthDays[m - 1];
if (m == 2 && isLeapYear(year)) {
answer++;
}
}
return answer + day;
}
private boolean isLeapYear(int year) {
// 四年一闰、百年不闰、四百年再闰,三条缺一不可,2100 正是被第二条排除的年份。
return year % 400 == 0 || (year % 4 == 0 && year % 100 != 0);
}
}
func daysBetweenDates(date1 string, date2 string) int {
days1 := countDays(date1)
days2 := countDays(date2)
// Go 的标准库没有整数版 abs,直接比较后返回正差值。
if days1 > days2 {
return days1 - days2
}
return days2 - days1
}
func countDays(date string) int {
// 定长格式,按固定下标切片,跳过位置 4 和 7 的分隔符。
year := atoi(date[:4])
month := atoi(date[5:7])
day := atoi(date[8:])
monthDays := []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
answer := 0
// 只累加到 year 的前一年,当年天数留给月份和日号细分。
for y := 1971; y < year; y++ {
if isLeapYear(y) {
answer += 366
} else {
answer += 365
}
}
// 同理只累加到 month 的前一月;跨过 2 月且当年闰年时补一天。
for m := 1; m < month; m++ {
answer += monthDays[m-1]
if m == 2 && isLeapYear(year) {
answer++
}
}
return answer + day
}
func isLeapYear(year int) bool {
// 四年一闰、百年不闰、四百年再闰,三条缺一不可。
return year%400 == 0 || (year%4 == 0 && year%100 != 0)
}
func atoi(s string) int {
num := 0
for i := 0; i < len(s); i++ {
// 字符减 '0' 得到该位的数字值,逐位左移累积。
num = num*10 + int(s[i]-'0')
}
return num
}
复杂度分析
- 时间复杂度:$O(Y + 12)$,其中 $Y$ 是日期年份与参考年 1971 的差。题目把年份上界定死在 2100,所以 $Y \le 129$、月循环至多 11 次,整体是常数级,可以直接记作 $O(1)$。如果想去掉这个循环,可以用「闰年个数 = $\lfloor y/4 \rfloor - \lfloor y/100 \rfloor + \lfloor y/400 \rfloor$ 的前缀差」把整年部分变成纯算术,但在本题的值域下没有必要。
- 空间复杂度:$O(1)$。只有一张长度固定为 12 的月长表和几个整型变量,与输入规模无关。
关键点总结
- 「求两个对象之间的距离」优先考虑先各自映射成一维序号,再相减取绝对值,而不是从一个模拟到另一个。这个套路在日期、时刻、坐标、进制转换题里通用,还顺带消灭了先后顺序判断。
- 参考点可以任选,因为公共偏移量在相减时抵消。想清楚这一点就不会纠结「编号应该从 0 还是从 1 开始」这种无关紧要的问题。
- 两个循环的上界都必须是开区间(
y < year、m < month),因为当前年和当前月是被更细的粒度接管的。累加型日期换算写错,八成错在这个<和<=上。
- 闰年三条规则要完整写出,且判断的必须是「日期本身所在的年」而不是循环变量。题目值域覆盖 2100 就是为了卡只写
% 4的实现。
- 日期题的考点是手写日期算术本身,用
LocalDate/SimpleDateFormat/time.Parse属于绕过考点。面试里可以提一句「工程上当然用标准库」,但白板上必须给出手写版本。
易错点总结
- 闰年只写
year % 4 == 0:"2100-02-28"与"2100-03-01"会算出 2 而不是 1,且题目值域内只有 2100 这一年能暴露它,本地随手测几组多半测不出来。
- 整年循环写成
y <= year:"2019-12-31"的编号会把 2019 整年重复计入,"2020-01-15"与它的间隔算出来是 15 - 365 = -350,取绝对值后变成 350。
- 整月循环写成
m <= month:"2020-01-15"会把 1 月整月的 31 天也加进去,编号偏大 31 天,任何跨月比较都错。
- 月长表下标写成
monthDays[m]而不是monthDays[m - 1]:m = 1时取到的是 2 月的 28 天,整条累加链集体错位,同时m = 12时还会数组越界。
- 闰年补天判断用了外层的循环变量
y而不是year:月循环里根本没有y这个变量(编译错误),若把两个循环合并写就会用错年份,导致给非闰年的 2 月补天。
- 把闰年补天的判断放在月循环外面无条件执行:
"2020-01-15"是闰年的 1 月,本不该被补,编号会多 1 天,与同年 2 月之前的日期比较全部偏 1。
- 假设
date1一定早于date2直接相减:输入date1 = "2020-01-15"、date2 = "2019-12-31"会返回 -15,题目要求返回 15。
- 用分隔符扫描或
split后忘记处理前导零:"2020-01-05"中的"01"、"05"带前导零,手写解析时若按「遇到 0 就跳过」的思路写会把"05"解析成 5 没问题,但把"10"之类的处理写反就会出错;按定长下标 + 逐位累积最稳。
- 用
LocalDate/SimpleDateFormat/time.Parse直接求差:能 AC,但整道题的考点(定长解析 + 闰年规则 + 累加换算)一个都没答到,面试中等同于没写。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1154. 一年中的第几天 | 简单 | 只算当年内的偏移、不跨年,正好是本题 countDays 的月份累加那一段 |
| 1118. 一月有多少天 | 简单 | 只需月长表加闰年 2 月判断,是本题最小的组成构件 |
| 539. 最小时间差 | 中等 | 同样把 HH:MM 压成一维分钟序号,但时间是环形的,还要考虑跨零点的一对 |
| LCR 035. 最小时间差 | 中等 | 与 539 同题,可直接套用序号化 + 排序 + 首尾环绕的写法 |
| 949. 给定数字能组成的最大时间 | 中等 | 重点在时间的合法性校验与全排列枚举,而非两个时刻之间的距离 |
| 415. 字符串相加 | 简单 | 同样禁止转换成整型库函数,考的是手写字符与数字的互转和进位 |
| 8. 字符串转换整数 (atoi) | 中等 | 本题 Go 版 atoi 的完整版,额外要处理空白、正负号、非法字符与溢出截断 |