目录

题目描述

1360. 日期之间隔几天

题意分析

给两个格式为 YYYY-MM-DD 的日期字符串 date1date2,返回它们之间相隔的天数。

题面有三条约束直接决定了写法。第一,日期格式严格固定为四位年、两位月、两位日,中间用 - 分隔,总长永远是 10 个字符,因此可以按固定下标切片解析,完全不需要任何分隔符扫描或正则。第二,日期范围是 1971-01-012100-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 <= yearf(2019,12,31) 会把 2019 整年再算一遍,结果直接偏掉 365 天。

再看一组专门打闰年规则的用例:"2100-02-28""2100-03-01"。正确答案是 1,因为 2100 年能被 4 整除但也能被 100 整除、且不能被 400 整除,是平年,2 月只有 28 天。若闰年函数只写 year % 4 == 0f(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 < yearm < 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 的完整版,额外要处理空白、正负号、非法字符与溢出截断