LeetCode 729. 我的日程安排表 I
题目描述
题意分析
设计一个日程表类,
book(start, end)尝试预定半开区间[start, end)。如果这次预定与任何一个已经成功预定的区间存在重叠,就拒绝并返回false,且这次预定不留下任何痕迹;否则接受、记录下来并返回true。「半开」是本题最先要吃透的设定。区间包含
start但不包含end,所以[10, 20)与[20, 30)是允许共存的——前者在 20 这一刻已经结束。判定两个半开区间[a, b)与[c, d)重叠的充要条件是 $\max(a, c) < \min(b, d)$,注意是严格小于。如果误用了小于等于,端点相接的合法预定就会被拒绝。「失败不留痕」是第二个关键点。必须先完成全部冲突检查、确认无冲突之后才写入,绝不能先插入再回滚。一旦采用「先插后删」的写法,中途返回的分支很容易漏掉删除,状态就被污染了。
约束透露的信号很明确:调用次数最多 1000 次,
0 ≤ start < end ≤ 10^9。调用次数只有千级,说明每次预定线性扫一遍已有区间的 $O(n)$ 做法(总计 $10^6$)也能通过,题目并不强制更优解;但值域高达 $10^9$ 且没有说是整数刻度密集使用,直接开值域数组是不可行的,必须以区间为单位存储。真正想考的是:你能否让每次查询只看位置上相邻的那一两个区间,而不是全部扫一遍。边界方面有三处:日程表为空时第一次预定必然成功,此时前驱和后继都不存在,判断逻辑要能容忍「查不到」;新区间落在所有已有区间之前时只有后继;落在最后时只有前驱。理想的实现应该让这三种情况都自然落进同一套判断,而不是各写一个特判分支。
解法:TreeMap / 有序映射维护区间
核心思路
最朴素的做法是把所有已接受的区间放进一个列表,每次
book都遍历整个列表,逐个用 $\max(a, c) < \min(b, d)$ 判定。这是对的,但每次预定都要看全部历史区间,第 $i$ 次调用做 $i$ 次比较,总代价 $O(n^2)$。瓶颈在于:绝大多数比较毫无意义——一个远在时间轴另一端的区间,根本不可能与当前区间冲突。顺着这个观察走下去:如果已有区间按起点有序存放,那么与新区间
[start, end)可能冲突的候选就被压缩到了极少数几个。更进一步,因为日程表内部始终保持两两互不重叠(这是这个数据结构的核心不变量,由每次插入前的检查保证),所以按起点排序等价于按终点排序,区间在时间轴上是首尾分离、严格递增排列的。这个不变量带来一个很强的结论:只需要检查起点方向上紧邻的前驱和后继两个区间。设
prev是起点不超过start的那个区间中起点最大的一个,next是起点不小于start的那个区间中起点最小的一个。任何比prev更靠前的区间,其终点不超过prev的起点,更不可能越过prev去和start之后的部分相交;同理任何比next更靠后的区间,起点不小于next的终点,更不可能小于end。所以两次检查就够了,不需要区间树。于是冲突条件可以针对这两个候选各写成一条,形式比通用公式更简洁:
与前驱冲突 $\iff$
prev.end > start。因为prev.start ≤ start已经成立,两区间的重叠条件 $\max$ 部分必然取start,$\min$ 部分取prev.end或end;而prev.end > start时若prev.end ≤ end则重叠区间是[start, prev.end)非空,若prev.end > end则重叠区间是[start, end)非空,两种情况都冲突。与后继冲突 $\iff$
end > next.start。对称地,start ≤ next.start成立,重叠的下界取next.start,上界取end与next.end的较小者;end > next.start时重叠区间非空。剩下的是选容器。Java 有现成的有序映射,
floorKey和ceilingKey正好对应上面两个候选,单次查询 $O(\log n)$。Go 标准库没有平衡树,所以用一个按起点升序的切片,自己二分找出「第一个起点不小于start的位置l」——这个l就是后继下标,l - 1就是前驱下标,一次二分同时定位两个候选。插入时把l之后的元素整体后移一位,维持有序性。
解题步骤
- 第一步,定位前驱:起点不超过
start的最后一个区间。 为什么用「不超过」而不是「严格小于」:如果已有区间的起点恰好等于start,它必定冲突(两个区间从同一时刻开始,而区间长度都为正),把它归入前驱能让这种情况被前驱检查一并覆盖,不需要单独判等。- 第二步,若前驱存在且
prev.end > start,立即返回false。 为什么只比终点不比起点:前驱的起点已经保证不超过start,所以是否重叠完全取决于它的终点有没有越过start。用严格大于是因为半开区间允许prev.end == start的首尾相接。- 第三步,定位后继:起点不小于
start的第一个区间。 为什么不是「严格大于start」:与第一步的划分对应,两次查找共同覆盖全体区间且不遗漏;起点等于start的区间既会被前驱检查抓到,也会被后继检查抓到,重复检查无害,遗漏才致命。- 第四步,若后继存在且
end > next.start,立即返回false。 为什么只比起点:后继的起点已经保证不小于start,是否重叠取决于新区间的终点有没有越过它的起点。- 第五步,两项检查都通过后才写入,并返回
true。 为什么必须放在最后:题目要求失败的预定不能改变日程表状态。把插入放在检查之后,所有失败分支都是纯粹的早退,不存在需要回滚的中间状态。- 第六步(Go 侧),插入时保持切片按起点升序。 为什么必须维持有序:二分查找的正确性完全依赖这个前提,一旦某次插入直接追加到尾部,后续所有二分结果都会失效,冲突检测彻底失灵。
以调用序列
book(10,20)、book(15,25)、book(20,30)、book(5,15)走一遍。
book(10, 20):日程表为空。查前驱,不存在,跳过第一项检查;查后继,不存在,跳过第二项。写入[10, 20),返回true。这一步展示了空表时两个「不存在」的判空为什么必须写在比较之前——先取值再判空会直接空指针或越界。
book(15, 25):查前驱,起点不超过 15 的最后一个区间是[10, 20)。检查prev.end = 20 > 15,成立,返回false。注意此时并没有执行任何写操作,日程表仍然只有[10, 20)。
book(20, 30):查前驱,起点不超过 20 的最后一个区间仍是[10, 20)。检查prev.end = 20 > 20,不成立,通过——这正是半开区间允许端点相接的体现,若这里写成>=就会误拒。查后继,起点不小于 20 的区间不存在,跳过。写入[20, 30),日程表变为[10,20), [20,30),返回true。
book(5, 15):查前驱,起点不超过 5 的区间不存在,跳过第一项检查。查后继,起点不小于 5 的第一个区间是[10, 20)。检查end = 15 > next.start = 10,成立,返回false。这一步专门检验了后继分支:如果只写了前驱检查而漏掉后继,这次预定会被错误接受,日程表里将同时存在互相重叠的[5,15)与[10,20),此后所有判定都建立在被污染的状态上。Go 侧同一序列的下标变化:第一次二分在空切片上得
l = 0,l > 0与l < len都不成立,两项检查自动跳过,插入后切片为[[10,20)]。第三次调用二分找「第一个起点 ≥ 20 的位置」,切片只有起点 10,得l = 1;l - 1 = 0对应[10,20),检查20 > 20不成立;l = 1已到末尾,后继不存在。插入到下标 1,切片为[[10,20),[20,30)]。第四次二分找「第一个起点 ≥ 5 的位置」得l = 0;l > 0不成立跳过前驱;l = 0 < 2,检查15 > 10成立,返回false。
代码实现
class MyCalendar {
private final TreeMap<Integer, Integer> map = new TreeMap<>();
public boolean book(int start, int end) {
Integer prevStart = map.floorKey(start);
if (prevStart != null && map.get(prevStart) > start) {
return false;
}
Integer nextStart = map.ceilingKey(start);
if (nextStart != null && end > nextStart) {
return false;
}
map.put(start, end);
return true;
}
}
type MyCalendar struct {
intervals [][2]int
}
func Constructor() MyCalendar {
return MyCalendar{intervals: make([][2]int, 0)}
}
func (c *MyCalendar) Book(start int, end int) bool {
l, r := 0, len(c.intervals)
for l < r {
mid := (l + r) / 2
if c.intervals[mid][0] < start {
l = mid + 1
} else {
r = mid
}
}
if l > 0 && c.intervals[l-1][1] > start {
return false
}
if l < len(c.intervals) && end > c.intervals[l][0] {
return false
}
c.intervals = append(c.intervals, [2]int{})
copy(c.intervals[l+1:], c.intervals[l:])
c.intervals[l] = [2]int{start, end}
return true
}
复杂度分析
- 时间复杂度:Java 版每次
book是 $O(\log n)$,floorKey、ceilingKey、put都是红黑树上的一次自根向下查找,$n$ 次调用总计 $O(n \log n)$。Go 版查询是 $O(\log n)$ 的二分,但插入需要把l之后的元素整体后移,最坏 $O(n)$,单次因此是 $O(n)$,总计 $O(n^2)$;在调用次数上限 1000 的约束下,最坏约 $5 \times 10^5$ 次元素搬移,完全可以接受。- 空间复杂度:$O(n)$,$n$ 为成功预定的区间数量。每个接受的区间恰好占一个条目,被拒绝的预定不占空间。除容器本身外只用了常数个下标变量。
关键点总结
- 先立不变量,再谈查询范围。这道题所有的效率优化都建立在「表内区间两两不重叠且按起点有序」这条不变量上。因为有它,才能从「和所有区间比」收缩到「只和前驱后继比」。遇到设计类题目,第一件事是写下数据结构在任意时刻满足的性质,它决定了你能省掉多少工作。
- 半开区间用严格不等号,闭区间才用等号。
prev.end > start和end > next.start里的严格大于是本题的分水岭。养成习惯:拿到区间题先确认端点开闭,再据此固定所有比较运算符的方向和严格性,之后写代码就不用反复纠结。- 前驱后继的划分要覆盖且可重叠,不能有缝。「起点 ≤ start」与「起点 ≥ start」两个查询在等号处重叠了一次,看似冗余,但这保证了任何已有区间至少被一次检查覆盖。设计双向查找时,宁可让两侧多重叠一格,也不要为了「不重复」而在中间留出一个谁都不管的位置。
- 有副作用的写入必须放在所有校验之后。这是「校验-提交」模式,不只适用于本题,也是并发编程与事务处理的通用纪律。任何「先改再判、失败回滚」的写法都会在多分支返回时留下漏网之鱼。
- 语言差异要影响容器选择,而不是算法选择。Java 有平衡树就用
TreeMap,Go 没有就用「有序切片 + 手写二分 + 插入搬移」。算法层面完全一致,只是插入的复杂度从 $O(\log n)$ 退化到 $O(n)$,而约束允许这种退化。- 面试视角:面试官几乎一定会追问两件事。第一是「为什么只查两个邻居就够」,标准答案是搬出不重叠不变量做归纳论证,而不是说「感觉不会冲突」。第二是「区间数量到 $10^5$、还要支持删除怎么办」,此时应答线段树(动态开点或离散化),并说明本题因为只有插入且不查询区间和,用有序映射就足够,上线段树属于杀鸡用牛刀。能主动说出「我的 Go 实现插入是 $O(n)$,在 1000 次调用的约束下可接受,但如果调用量上到 $10^5$ 就需要换成平衡树或跳表」,比闭口不谈更能体现工程判断。
易错点总结
- 错误写法:前驱检查用
prev.end >= start。以book(10,20)后再book(20,30)为例,prev.end = 20 >= 20成立,第二次预定被错误拒绝,返回false,而正确答案是true。半开区间的首尾相接是合法的,这是本题第一大错误。- 错误写法:后继检查用
end >= next.start。以book(20,30)后再book(10,20)为例,end = 20 >= next.start = 20成立,被错误拒绝,正确答案是true。与上一条对称,两处都要用严格大于。- 错误写法:只检查前驱,漏掉后继。以
book(10,20)后再book(5,15)为例,起点不超过 5 的区间不存在,前驱检查直接放行,[5,15)被错误接受;此后日程表里同时有[5,15)与[10,20)两个重叠区间,book(12,13)也会因为前驱[10,20)被拒——状态已被污染,后续结果全不可信。- 错误写法:先
put写入再做冲突检查,失败时删除。以book(10,20)后再book(10,15)为例,put(10, 15)会直接覆盖掉已有的[10,20)(键相同),即便随后删除也无法恢复原值,日程表凭空少了一个已接受的预定。有序映射按起点做键时,这种写法会静默丢数据。- 错误写法:取到
floorKey或ceilingKey后不判空就取值。以第一次调用book(10,20)为例,空表上map.floorKey(10)返回null,直接map.get(prevStart)得到null再拆箱比较,抛NullPointerException;Go 侧对应的是l - 1为-1时访问c.intervals[-1]触发下标越界 panic。判空必须写在比较之前,靠短路求值保护。- 错误写法:Go 二分的判定条件写成
c.intervals[mid][0] <= start。以已有[[10,20)]再book(10,15)为例,二分会把l推到 1,前驱检查的是l-1 = 0即[10,20),20 > 10成立仍能拒绝——但换成已有[[10,20)]再book(10,10)这类退化输入,边界语义已经偏移,l的含义不再是「第一个起点 ≥ start 的位置」,后继检查会跳过本该检查的区间。二分的判定式必须严格对应你想要的那个下界语义。- 错误写法:Go 侧插入直接
append到切片尾部,不做搬移。以book(20,30)后再book(5,10)为例,切片变成[[20,30),[5,10)],不再有序;下一次book(6,8)的二分会在无序数组上给出错误的l,把明显重叠的预定判为通过,返回true,正确答案是false。- 错误写法:Go 侧用
copy(c.intervals[l:], c.intervals[l+1:])做搬移(方向写反)。以已有[[10,20),[30,40)]再book(0,5)为例,本应把两个元素右移,实际却把后面的元素左移覆盖了前面,切片内容变成[[0,5),[30,40),[30,40)],丢失了[10,20),之后book(15,16)会被错误接受。右移必须从后往前拷贝,copy的源和目标区间要写成copy(dst[l+1:], src[l:])。- 错误写法:把冲突条件直接写成通用式但取错方向,如
max(a,c) <= min(b,d)。以[10,20)与[20,30)为例,$\max(10,20) = 20$,$\min(20,30) = 20$,20 <= 20成立被判为重叠,返回false,正确答案是true。通用式必须是严格小于。- 错误写法:以为
end也需要单独二分定位一次。以book(10,50)在已有[[20,30)]时为例,只查start = 10的前驱后继:后继是[20,30),50 > 20成立,正确返回false。多做一次基于end的二分不会改变结果,却会让两处下标语义交叉,实现中极易把l和另一个下标用混。本题一次二分足矣。- 错误写法:Java 里把
Integer与int混用做比较,如if (prevStart != null && map.get(prevStart) > start)写成map.get(prevStart).equals(start)之类。以book(10,20)后book(20,30)为例,equals比较的是相等而非大小,前驱检查完全失效,任何重叠预定都会被放行。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 732. 我的日程安排表 III | 困难 | 允许任意重叠,要返回最大重叠层数,需差分计数或动态开点线段树 |
| 56. 合并区间 | 中等 | 一次性给全部区间,排序后线性合并,是离线版本而非在线设计 |
| 57. 插入区间 | 中等 | 插入时不拒绝而是与相交区间合并,重点在三段式扫描的边界拼接 |
| 435. 无重叠区间 | 中等 | 目标从「能否插入」变成「最少删几个」,需按右端点贪心 |
| 253. 会议室 II | 中等 | 允许重叠但要算最少房间数,用最小堆维护正在进行的会议结束时间 |
| 252. 会议室 | 简单 | 只判断全体是否两两不重叠,排序后比较相邻两项即可,是本题的静态简化版 |
| 986. 区间列表的交集 | 中等 | 求两个有序区间列表的全部交集,用双指针同时推进而非二分查找 |
| 1288. 删除被覆盖区间 | 中等 | 判定的是包含关系而非相交,排序时右端点要降序才能一次扫完 |
| LCR 058. 我的日程安排表 I | 中等 | 与本题同题,可直接套用同一套前驱后继检查 |