目录

题目描述

LCR 035. 最小时间差

题意分析

给一组 "HH:MM" 格式的时刻,求任意两个时刻之间的最小分钟差

第一层转换很直接:字符串比较没有算术意义,"23:59""00:00" 按字符串看相隔很远,实际只差 1 分钟。所以第一步必须把时刻统一成一个可做减法的整数——从零点起算的分钟数,范围是 [0, 1439]

第二层是「时间是循环的」。一天有 1440 分钟,23:59 与次日 00:00 只差 1 分钟。所以两个时刻 a < b 的真实差值是 $\min(b - a,\ 1440 - (b - a))$,跨零点这条路径不能忽略。

第三层是取值范围带来的强信号:合法时刻只有 1440 种。如果输入超过 1440 个时刻,按抽屉原理必然存在两个完全相同的时刻,答案直接是 0。这条既是正确性的边界,也是把最坏规模压到常数的剪枝——没有它,输入可能有十万条记录,排序虽然也能过,但这条判断让复杂度上界与输入规模脱钩。

边界还包括:存在重复时刻时答案为 0;只有两个时刻时仍要考虑跨零点;全部时刻集中在一天两端时最小差恰好出现在首尾之间。

解法:排序后扫描

核心思路

暴力做法是枚举所有时刻对求差取最小,$O(n^2)$ 次比较。瓶颈很清楚:绝大多数配对根本不可能是答案——两个相距很远的时刻永远不会贡献最小值。

观察:把所有时刻按分钟数从小到大排好之后,最小差一定出现在排序后相邻的两个时刻之间。因为若 a < c < b,那么 b - a 必然不小于 c - ab - c,中间隔着别的元素的配对永远不可能更优。于是候选从 $O(n^2)$ 对锐减到 $n - 1$ 对。

但这只覆盖了「不跨零点」的那些配对。跨零点的差值只可能出现在最大时刻与最小时刻之间(绕一圈的那段),其余任何跨零点配对都比它更长。

把这条特殊配对也并入统一扫描的技巧是:在排好序的数组末尾追加一个虚拟元素 nums[0] + 1440,表示「最小时刻在第二天的对应时刻」。这样最后一对相邻元素 nums[n-1]nums[0] + 1440 的差,正好就是绕过零点的那段距离,跨零点情形被彻底吸收进同一个循环,不需要任何特判。

于是不变量是:扫描到第 i 个位置时,answer 等于前 i 对相邻元素差值的最小值;扫完全部 n 对(含虚拟元素那一对)后,answer 就是全局答案。

最后再补上抽屉原理的剪枝:时刻数超过 1440 直接返回 0。这一步要放在最前面,它同时保证了后续排序的规模不会超过 1440。

解题步骤

  • 抽屉剪枝if (timePoints.size() > 1440) return 0;。1440 是一天的分钟总数,超过就必有重复,答案为 0。放在最前面既是正确性判断,也把后续所有操作的规模钉死在常数级。
  • 开长度 n + 1 的数组:多出来的那一格是给虚拟元素预留的,避免后面再做数组扩容或额外分支。
  • 解析成分钟数:按 ":" 切分,小时 * 60 + 分钟。统一量纲是所有时间题的第一步,之后就只剩纯整数运算。
  • 只对前 n 个元素排序Arrays.sort(nums, 0, n)。第 n 格此刻还是占位的 0,若把它一起排序会被当成一个真实的零点时刻混进来,答案会被污染。
  • 追加虚拟元素nums[n] = nums[0] + 1440,必须在排序之后赋值,因为要用的是排序后的最小值。它的语义是「最早那个时刻在第二天出现一次」,让绕零点的距离变成一次普通的相邻差。
  • 扫描相邻差取最小for (int i = 1; i <= n; ++i) answer = min(answer, nums[i] - nums[i-1])。上界写 <= n 才能把虚拟元素那一对算进去。初值取一个大于 1440 的数(这里是 1 << 30)即可,任何真实差值都会把它替换掉。
  • 返回 answer

timePoints = ["23:59", "00:00"] 走一遍。数量 2 不超过 1440。解析得 nums = [1439, 0, 占位];对前两个排序得 [0, 1439, 占位];追加虚拟元素 nums[2] = 0 + 1440 = 1440,数组变成 [0, 1439, 1440]

扫描:i=1 时差值 1439 - 0 = 1439answer = 1439i=2 时差值 1440 - 1439 = 1answer = 1。返回 1,与「23:59 到次日 00:00 只差一分钟」一致。如果没有虚拟元素,答案会错成 1439。

再看含重复的用例 ["00:00", "23:59", "00:00"]:数量 3 未触发剪枝。解析得 [0, 1439, 0],排序前三个得 [0, 0, 1439],追加 nums[3] = 0 + 1440 = 1440。扫描:i=1 差值 0,answer = 0;后面两轮的差值 1439 与 1 都不会更小。返回 0,正确——重复时刻的差值天然是 0,不需要单独判重。

最后看剪枝生效的情形:输入 1441 个时刻时直接返回 0,因为可用的不同时刻只有 1440 种,必然有两个撞在一起。

代码实现

class Solution {
    public int findMinDifference(List<String> timePoints) {
        // 抽屉原理:超过一天的分钟总数必有重复。
        if (timePoints.size() > 1440) {
            return 0;
        }
        int n = timePoints.size();
        // 多留一格给「最小时刻的第二天副本」。
        int[] nums = new int[n + 1];
        for (int i = 0; i < n; ++i) {
            String[] t = timePoints.get(i).split(":");
            nums[i] = Integer.parseInt(t[0]) * 60 + Integer.parseInt(t[1]);
        }
        // 只排真实数据,别把占位的那一格排进来。
        Arrays.sort(nums, 0, n);
        // 把跨零点的差值变成一次普通的相邻差。
        nums[n] = nums[0] + 1440;
        int answer = 1 << 30;
        for (int i = 1; i <= n; ++i) {
            answer = Math.min(answer, nums[i] - nums[i - 1]);
        }
        return answer;
    }
}
func findMinDifference(timePoints []string) int {
    // 抽屉原理:超过一天的分钟总数必有重复。
    if len(timePoints) > 1440 {
        return 0
    }

    n := len(timePoints)
    // 多留一格给「最小时刻的第二天副本」。
    nums := make([]int, n+1)
    for i, time := range timePoints {
        parts := strings.Split(time, ":")
        hours, _ := strconv.Atoi(parts[0])
        minutes, _ := strconv.Atoi(parts[1])
        nums[i] = hours*60 + minutes
    }

    // 只排真实数据,别把占位的那一格排进来。
    sort.Ints(nums[:n])
    // 把跨零点的差值变成一次普通的相邻差。
    nums[n] = nums[0] + 1440

    answer := 1 << 30
    for i := 1; i <= n; i++ {
        answer = min(answer, nums[i]-nums[i-1])
    }

    return answer
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,瓶颈在排序;解析与扫描各是一次线性遍历。由于抽屉剪枝把进入排序的元素数钉死在 1440 以内,实际运行时间与输入规模无关,可视为常数级。
  • 空间复杂度:$O(n)$,用来存放转换后的分钟数组(含一格虚拟元素)。同样因为剪枝,这个数组最多 1441 个整数,实际是常数级;排序若使用递归实现还会带来 $O(\log n)$ 的栈开销。

关键点总结

  • 不同格式的数据先统一量纲再计算,时间类题目一律先化成「从某个零点起算的最小单位数」,之后所有逻辑都退化成整数运算。
  • 「最小差值」在排序后只可能出现在相邻元素之间,这条性质把候选从 $O(n^2)$ 对压到 $O(n)$ 对,是所有「最接近的一对」问题的通用起手式。
  • 环形结构的首尾配对,用「在末尾追加 首元素 + 周期」的技巧并入普通相邻扫描,比写一个额外的特判分支更不容易漏,这个手法在环形数组、循环队列类题目里可反复复用。
  • 值域有限时先用抽屉原理剪枝,既处理了必然重复的边界,又把最坏规模从输入长度降到值域大小。
  • 排序时要划清「真实数据」与「预留位」的边界,把占位元素一起排进去是这类「多留一格」写法的隐蔽错误。
  • 面试视角:先说 $O(n^2)$ 暴力确认题意,再讲排序加相邻扫描,最后主动补上抽屉剪枝和跨零点处理。面试官常追问「能不能不排序」,答案是把 1440 个时刻做成布尔计数桶,按桶顺序线性扫描,时间降到 $O(n + 1440)$——能说出这个桶排思路是明显的加分项。

易错点总结

  • 忘记跨零点的配对["23:59", "00:00"] 只扫排序后的相邻差会得到 1439,而正确答案是 1。
  • 把占位格一起排序Arrays.sort(nums) 会把第 n 格的 0 当成一个真实的零点时刻,["23:59", "12:00"] 会因这个虚假的 0 而得到错误的更小差值。
  • 在排序之前就写 nums[n] = nums[0] + 1440:此时 nums[0] 还是输入里的第一个时刻而非最小值,["23:59", "00:00"] 会算出 1439 + 1440 的错误虚拟点。
  • 漏掉抽屉剪枝:输入十万条记录时排序虽然仍能得出正确答案,但白白付出 $O(n \log n)$ 的代价,且在更严格的时限下会超时。
  • 扫描上界写成 i < n:虚拟元素那一对没被计算,跨零点的差值再次丢失,["23:59", "00:00"] 回到错误的 1439。
  • 直接比较字符串大小"23:59""00:00" 按字典序相差极大,得到的差值毫无意义。
  • 答案初值取 1440 或更小:若初值取 1440,恰好等于某些边界差值时无法区分是否被更新过;取一个明显大于 1440 的数(如 1 << 30)更安全。
  • 解析时把小时当成分钟直接相减"01:30""02:00" 会被算成差 1 而不是 30,量纲不统一导致全盘皆错。
  • 认为重复时刻需要单独判等["00:00", "00:00"] 排序后相邻差自然是 0,额外的判重逻辑是多余的。

相似题目

题目 难度 考察点
539. 最小时间差 中等 与本题同题,可直接套用排序加环形虚拟点
1200. 最小绝对差 简单 同样靠排序后相邻扫描找最小差,但不是环形且要返回所有达到最小差的对
164. 最大间距 中等 求的是相邻最大差且要求线性时间,需要用桶划分而不能直接排序
220. 存在重复元素 III 困难 差值约束外还加了下标距离约束,要用有序集合维护滑动窗口
56. 合并区间 中等 同样先排序再线性扫描相邻元素,但处理的是区间重叠而非点距
628. 三个数的最大乘积 简单 同样靠排序把最优解锁定在两端的少数候选上,避免枚举全部组合