目录

题目描述

1154. 一年中的第几天

题意分析

给一个形如 YYYY-MM-DD 的日期字符串,返回这一天是当年的第几天。一月一日算第 1 天。

格式是完全固定的:年份四位、月份两位、日期两位、分隔符是横线,位数不足会补前导零。这个信号很关键——它意味着每个字段的起止下标是常量,不需要按分隔符切分,也不用担心 2019-1-9 这种不规则写法。

题目还保证日期本身合法,年份范围在 19002019 之间。合法性有保证,所以不用校验「二月三十日」这类输入;而年份范围恰好跨过 19002000 两个特殊年份,正好能考出对世纪年规则的理解。

边界要单独想清楚三种:月份是一月时不需要累加任何整月;日期不跨过二月时,是不是闰年根本不影响答案;日期在二月之后且当年是闰年时,必须多算一天。

解法:月份天数累加

核心思路

一个想当然的思路是先算出「从元旦到这一天的天数差」,可要真去做日期减法,反而要重新处理进位和月长,绕了一大圈。瓶颈在于把问题想复杂了:既然起点固定是当年一月一日,答案其实就是一个前缀和。

把年内的日子按月切段来看,答案自然分成两块:完整走过的那些月份的天数总和,加上当月已经过去的天数。后者就是 day 本身(因为一号算第一天),前者是 1month - 1 月的月长之和。

月长里只有二月是变量,其余十一个月都是常量。所以整个问题被压缩成一件事:判断这一年是不是闰年。公历的闰年规则是「四年一闰,百年不闰,四百年再闰」,写成条件就是「能被 400 整除,或者能被 4 整除但不能被 100 整除」。

不变量是:循环累加到第 i 轮时,answer 恒等于「当年前 i 个月的总天数」加上 day 初始 i = 0answer 就是 day,符合定义;每轮把第 i + 1 个月的月长加进去,不变量保持;循环在 i 达到 month - 1 时停止,此时累加的正好是当前月之前的所有整月,answer 即为答案。

解题步骤

  • 手写一个 parseNumber 从固定下标区间截取数字。年份取 [0, 4)、月份取 [5, 7)、日期取 [8, 10),跳过下标 47 上的横线。用「乘十加个位」逐位累积,而不是调用现成的字符串转数字接口,是因为面试里这一步本身就是考点。
  • 建一张十二个月的天数表,二月先按平年的 28 天填。用数组而不是一串条件判断,是为了让后续累加变成一个干净的循环。
  • 判断闰年,若成立就把表里二月改成 29。改表比在累加时加特判更好,因为它把「闰年」这个信息一次性收敛到数据里,后面的逻辑完全不必再关心年份。
  • answer = day 作为起点,这一步已经把当月的天数计入。
  • 循环 i0month - 2,把 days[i] 累加进 answer。上界写成 month - 1(不含)是因为数组下标比月份编号小一,而当前月本身不能整月计入。
  • 返回 answer

date = "2019-02-10" 走一遍:解析得 year = 2019month = 2day = 10。闰年判断:2019 % 40019 不为零;2019 % 43 不为零,所以整个条件为假,二月保持 28 天。answer 初始为 10。循环上界是 month - 1 = 1,只跑 i = 0 一轮,加上一月的 31 天,answer 变成 41。返回 41。再以 date = "2004-03-01" 走一遍:解析得 year = 2004month = 3day = 1。闰年判断:2004 % 4004 不为零,但 2004 % 4 为零且 2004 % 1004 不为零,条件成立,days[1] 改成 29answer 初始为 1;循环跑两轮,i = 0 加一月的 3132i = 1 加二月的 2961。返回 61。最后以 date = "1900-03-01" 检验世纪年:1900 % 400300 不为零;1900 % 4 为零但 1900 % 100 也为零,所以后半个条件为假,1900 不是闰年,二月仍是 28 天,答案是 31 + 28 + 1 = 60,比 2004 年同一天少一天。

代码实现

// 一年中某天的序号可以拆成两部分:当前月之前所有月份的天数总和,再加上当前日期。
class Solution {
    public int dayOfYear(String date) {
        int year = parseNumber(date, 0, 4);
        int month = parseNumber(date, 5, 7);
        int day = parseNumber(date, 8, 10);
        int[] days = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

        if (isLeapYear(year)) {
            days[1] = 29;
        }

        int answer = day;
        for (int i = 0; i < month - 1; i++) {
            answer += days[i];
        }

        return answer;
    }

    private int parseNumber(String date, int start, int end) {
        int value = 0;
        for (int i = start; i < end; i++) {
            value = value * 10 + date.charAt(i) - '0';
        }

        return value;
    }

    private boolean isLeapYear(int year) {
        return year % 400 == 0 || year % 4 == 0 && year % 100 != 0;
    }
}
// 一年中某天的序号可以拆成两部分:当前月之前所有月份的天数总和,再加上当前日期。
func dayOfYear(date string) int {
    year := parseNumber(date, 0, 4)
    month := parseNumber(date, 5, 7)
    day := parseNumber(date, 8, 10)
    days := []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}

    if isLeapYear(year) {
        days[1] = 29
    }

    answer := day
    for i := 0; i < month-1; i++ {
        answer += days[i]
    }

    return answer
}

func parseNumber(date string, start int, end int) int {
    value := 0
    for i := start; i < end; i++ {
        value = value*10 + int(date[i]-'0')
    }

    return value
}

func isLeapYear(year int) bool {
    return year%400 == 0 || year%4 == 0 && year%100 != 0
}

复杂度分析

  • 时间复杂度:$O(1)$,输入长度恒为 10,解析三个字段各扫固定位数,累加循环最多跑 11 轮,全部与任何可变规模无关。
  • 空间复杂度:$O(1)$,只有一张长度为 12 的常量月长表和几个整型变量,都不随输入变化。

关键点总结

  • 「第几天」这类问题的通用形态是前缀和:把周期切成段,答案等于完整段的总和加上当前段内的偏移。想清楚这一点,就不必去做真正的日期减法。
  • 把可变因素收敛进数据而不是散落在控制流里。这里把闰年的影响一次性写进月长表,后续累加逻辑就完全不必再感知年份,代码分支数直接减半。
  • 格式完全固定的字符串应该按下标直接取,而不是先切分再转换。少一层中间结构就少一层出错空间。
  • 闰年规则必须完整写出三条,尤其是「百年不闰、四百年再闰」这一层。只写 year % 4 == 01900 这类年份上就会错,而这题的年份范围恰好包含 1900
  • 面试视角:这题真正的考点是手写解析和闰年判断,直接调用日期库会让考察点全部落空。要主动写出 parseNumber 而不是用现成接口,并说明为什么。
  • 面试视角:常见追问是「怎么反过来,从第几天还原成日期」。答案是从月长表里依次扣减,直到不够扣为止,剩下的就是日;能顺带说明闰年表要先改好,会显得考虑周全。

易错点总结

  • 错误写法:闰年只判断 year % 4 == 0。用例 "1900-03-01"1900 能被 4 整除但它是世纪年且不能被 400 整除,实际不是闰年;该写法会把二月算成 29 天,返回 61 而正确答案是 60
  • 错误写法:闰年写成 year % 100 != 0 || year % 400 == 0 却漏掉 year % 4 == 0。用例 "2019-03-01"2019 不能被 100 整除,条件直接为真,被误判成闰年,返回 61 而正确答案是 60
  • 错误写法:累加循环写成 for (int i = 0; i < month; i++)。用例 "2019-01-09" → 把当前月的整月天数也加了进去,返回 9 + 31 = 40,而正确答案是 9
  • 错误写法answer 初始化为 0 后只累加整月天数,忘记加上 day。用例 "2019-02-10" → 返回 31,少了当月已过的十天。
  • 错误写法:解析月份时取下标区间 [4, 7)。用例 "2019-02-10" → 把横线也读进去,'-' - '0' 得到负数,月份被算成一个错误值,后续循环次数完全不对甚至越界。
  • 错误写法:假设日期字段没有前导零,用「按横线切分再取第二段的首字符」之类的方式解析。用例 "2019-02-10" → 月份读成 0,循环一次都不跑,返回 10 而正确答案是 41
  • 错误写法:把月长表写成从下标 1 开始的十三个元素,却仍然用 days[i] 配合 i0 起步。用例 "2019-03-05" → 首元素是占位值,累加结果整体错位,返回值比正确的 64 少或多一个月的天数。
  • 错误写法:判定闰年后直接给 answer 加一,而不区分月份。用例 "2004-01-15" → 一月根本没跨过二月,闰年与否毫不相干,却被多算一天,返回 16 而正确答案是 15
  • 错误写法:Java 里写 date.charAt(i) - '0' 时忘记运算符优先级,把表达式写成 value * 10 + date.charAt(i) - '0' 之外的形式,例如 value * 10 + (date.charAt(i) - '0') * 10。用例 "2019-02-10" → 每一位都被额外放大十倍,解析出的年月日全部失真。
  • 错误写法:把月长表声明为静态常量并在闰年时就地改成 29,却不在下次调用前还原。用例先跑 "2004-03-01" 再跑 "2019-03-01" → 第二次调用沿用了上一次留下的 29,返回 61 而正确答案是 60

相似题目

题目 难度 考察点
1360. 日期之间隔几天 简单 跨年份求差值,需把日期统一折算成绝对天数
1344. 时钟指针的夹角 中等 同样是周期换算,重点在分针带动时针的连续位移
8. 字符串转换整数 (atoi) 中等 格式不固定,需处理空白、符号与溢出截断
93. 复原 IP 地址 中等 分隔位置未知,要靠回溯枚举并校验每段合法性
468. 验证IP地址 中等 重点在前导零、段数、进制等格式规则的完整覆盖
989. 数组形式的整数加法 简单 逐位处理并维护进位,考察手写数字运算的细心度