729. 我的日程安排表 I
729. 我的日程安排表 I
题目描述
实现一个 MyCalendar 类来存放你的日程安排。如果要添加的时间区间不会导致 重复预订,则可以存储这个新的日程安排。
当两个日程安排有一些时间上的交叉时,就会产生重复预订。
这里的时间用半开区间表示,即 [start, end),实数 x 的范围为:
start <= x < end
实现 MyCalendar 类:
MyCalendar()初始化日历对象。boolean book(int start, int end)如果可以将日程安排成功添加到日历中而不会导致重复预订,返回true;否则返回false并且不要将该日程安排添加到日历中。
示例
输入:
["MyCalendar", "book", "book", "book"]
[[], [10, 20], [15, 25], [20, 30]]
输出:
[null, true, false, true]
解释
MyCalendar myCalendar = new MyCalendar();
myCalendar.book(10, 20); // return True
myCalendar.book(15, 25); // return False ,因为 [15, 25) 和 [10, 20) 重叠
myCalendar.book(20, 30); // return True ,因为 [20, 30) 和 [10, 20) 不重叠
约束
0 <= start < end <= 10^9- 调用
book方法的次数最多为1000
核心思路
这题的本质只有一句话:
每次插入新区间时,判断它是否和已有区间重叠。
关键点在于怎么判断两个区间是否重叠。
假设已有区间是 [s1, e1),新区间是 [s2, e2)。
它们 不重叠 的情况只有两种:
- 新区间完全在左边:
e2 <= s1 - 新区间完全在右边:
s2 >= e1
那么它们 重叠 的条件就是上面两种情况的反面:
start < existedEnd && end > existedStart
也可以记成:
Math.max(start, existedStart) < Math.min(end, existedEnd)
这题至少有两种常见做法:
- 解法一:顺序扫描所有已预订区间
- 解法二:按开始时间有序存储 + 二分查找插入位置
解法一:顺序扫描所有区间
这是最直接、最适合第一次做这题的做法。
思路拆解
我们用一个数组 books 存所有已经成功预订的区间。
每次调用 book(start, end):
- 遍历
books中每一个旧区间 - 判断新区间和旧区间是否重叠
- 如果重叠,直接返回
false - 如果全部都不重叠,就把新区间加入数组并返回
true
由于最多调用 1000 次,O(n) 扫描完全够用。
一步一步写出算法
第一步:先定义数据结构
我们需要一个数组来保存所有已经预订成功的区间。
class MyCalendar {
private books: [number, number][]
constructor() {
this.books = []
}
}
第二步:思考怎么判断是否重叠
对于旧区间 [left, right),如果满足:
start < right && end > left
说明有交集,也就是重叠。
为什么这样判断?
start < right说明新区间左端点在旧区间右端点左边end > left说明新区间右端点在旧区间左端点右边- 两者同时成立,说明它们真正压在了一起
第三步:遍历所有旧区间
for (const [left, right] of this.books) {
if (start < right && end > left) {
return false
}
}
第四步:如果没有重叠,就加入数组
this.books.push([start, end])
return true
代码实现(TypeScript)
class MyCalendar {
private books: [number, number][]
constructor() {
this.books = []
}
book(start: number, end: number): boolean {
for (const [left, right] of this.books) {
if (start < right && end > left) {
return false
}
}
this.books.push([start, end])
return true
}
}
复杂度分析
- 单次
book时间复杂度:O(n) - 总时间复杂度:
O(n^2) - 空间复杂度:
O(n)
这里的 n 是成功预订的区间数量。
这一解法的优缺点
优点:
- 最容易理解
- 最容易写对
- 对这题的数据范围已经足够
缺点:
- 每次都要扫一遍已有区间
- 当预订次数更多时,不够高效
解法二:按开始时间有序存储 + 二分查找
这个做法的重点是:
如果区间按开始时间有序,那么新区间只需要检查“插入位置附近”的区间即可。
为什么只检查相邻区间?
假设我们已经把所有区间按 start 从小到大排序。
现在要插入新区间 [start, end),并且它最终应该放在下标 idx 的位置。
那么:
- 它左边最可能和它冲突的是
idx - 1 - 它右边最可能和它冲突的是
idx
因为更左边的区间结束得更早,更右边的区间开始得更晚。
所以我们不用扫描全部区间,只要检查左右邻居即可。
一步一步写出算法
第一步:仍然先定义数组
只是这次要求数组始终按开始时间有序。
class MyCalendar {
private books: [number, number][]
constructor() {
this.books = []
}
}
第二步:二分查找插入位置
我们要找到第一个 books[mid][0] >= start 的位置。
let left = 0
let right = this.books.length
while (left < right) {
const mid = Math.floor((left + right) / 2)
if (this.books[mid][0] < start) {
left = mid + 1
} else {
right = mid
}
}
循环结束后,left 就是新区间应该插入的位置。
第三步:检查左边邻居
如果 left - 1 存在,那么它是新区间左边最近的区间。
只要它的结束时间大于新区间开始时间,就冲突。
if (left > 0 && this.books[left - 1][1] > start) {
return false
}
第四步:检查右边邻居
如果 left 还在数组范围内,那么它就是新区间右边最近的区间。
只要新区间结束时间大于右边邻居的开始时间,就冲突。
if (left < this.books.length && end > this.books[left][0]) {
return false
}
第五步:在正确位置插入新区间
this.books.splice(left, 0, [start, end])
return true
代码实现(TypeScript)
class MyCalendar {
private books: [number, number][]
constructor() {
this.books = []
}
book(start: number, end: number): boolean {
let left = 0
let right = this.books.length
while (left < right) {
const mid = Math.floor((left + right) / 2)
if (this.books[mid][0] < start) {
left = mid + 1
} else {
right = mid
}
}
if (left > 0 && this.books[left - 1][1] > start) {
return false
}
if (left < this.books.length && end > this.books[left][0]) {
return false
}
this.books.splice(left, 0, [start, end])
return true
}
}
复杂度分析
- 二分查找:
O(log n) - 数组插入:
O(n) - 单次
book总时间复杂度:O(n) - 空间复杂度:
O(n)
虽然插入仍然是 O(n),但相比暴力扫描,它的“查找冲突区间”更有思路,也更接近更高级数据结构的做法。
两种解法对比
| 解法 | 查找方式 | 单次时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|---|
| 顺序扫描 | 遍历全部区间 | O(n) | O(n) | 最直观,最适合入门 |
| 有序数组 + 二分 | 二分定位 + 邻居检查 | O(n) | O(n) | 思路更进阶,更接近面试优化 |
注意:
- 第二种方法虽然用了二分,但由于数组插入需要移动元素,所以单次仍是
O(n)。 - 如果底层是平衡树,那么查找和插入都可以更高效,但在 LeetCode 的 TypeScript 环境里通常不会这么写。
手动模拟一遍
以题目示例为例:
book(10, 20) -> true
book(15, 25) -> false
book(20, 30) -> true
第一次:book(10, 20)
当前还没有任何区间。
[]
直接插入:
[10, 20)
返回 true。
第二次:book(15, 25)
检查和 [10, 20) 是否重叠:
15 < 20 并且 25 > 10
说明重叠,所以返回 false。
日历不变:
[10, 20)
第三次:book(20, 30)
检查和 [10, 20) 是否重叠:
20 < 20 ? 不成立
因为这是半开区间,[10, 20) 和 [20, 30) 只是刚好相接,不算重叠。
所以可以插入,返回 true。
最终日历:
[10, 20), [20, 30)
面试时最容易错的点
1. 别把“相接”当成“重叠”
这题是半开区间:
[10, 20) 和 [20, 30)
它们 不重叠。
所以判断时要用:
start < right && end > left
而不是写成 <= 或 >=。
2. 重叠条件建议直接背下来
两个区间 [a, b) 和 [c, d) 重叠,当且仅当:
a < d && b > c
在本题中就是:
start < right && end > left
3. 二分法里只检查相邻区间
很多人会觉得二分找到位置后,还要继续往两边扫。
其实不用。
因为区间已经按开始时间排序,真正可能冲突的只会是插入位置左右两侧的邻居。
一句话总结
这题本质上是:
维护一组互不重叠的区间,每次插入新区间时判断它是否和已有区间冲突。
- 想先写对:用 顺序扫描
- 想把思路再进一层:用 有序数组 + 二分查找
推荐你最后记住的标准写法
如果你准备用 TypeScript 刷题,最推荐记住下面这个版本:
class MyCalendar {
private books: [number, number][]
constructor() {
this.books = []
}
book(start: number, end: number): boolean {
for (const [left, right] of this.books) {
if (start < right && end > left) {
return false
}
}
this.books.push([start, end])
return true
}
}
原因很简单:
- 最稳
- 最不容易写错
- 对这题的数据范围完全够用