MyCalendar 中重疊判定邏輯的解說
範圍 — 只講一件事 — 區間重疊的判定式:閉區間
[a,b]vs 半開區間[a,b),以及每一題 LC 各需要哪一種。 另見:intervals.md — 會判定重疊之後該怎麼用;scanning_line.md;difference_array.md。
- (LC 729)
這份文件解說 MyCalendar 類別中下面這段程式碼的邏輯:
if (start < date.get(1) && end > date.get(0)) {
return false; // There's an overlap
}
LeetCode 題目清單
目的
這段邏輯是要檢查新的預約是否和行事曆上已有的預約重疊。如果有重疊,函式會回傳 false,表示這筆預約不能成立。
圖解
想像一條時間軸,每個事件都有開始和結束時間。新事件用 start 和 end 表示,既有事件則用 date.get(0)(開始)和 date.get(1)(結束)表示。
各種情況:
1. 沒有重疊 — 新事件完全在既有事件之前
New: |-----|
Existing: |-----|
- 條件:
end <= date.get(0) - 說明:新事件在既有事件開始之前就結束了,所以沒有重疊。
2. 沒有重疊 — 新事件完全在既有事件之後
New: |-----|
Existing: |-----|
- 條件:
start >= date.get(1) - 說明:新事件在既有事件結束之後才開始,所以沒有重疊。
3. 有重疊 — 新事件和既有事件部分重疊
New: |-------|
Existing: |------|
- 條件:
start < date.get(1)且end > date.get(0) - 說明:新事件在既有事件結束前就開始、又在既有事件開始後才結束,於是部分重疊。
4. 完全被包住 — 新事件的頭尾都落在既有事件之內
New: |---|
Existing: |-------|
- 條件:
start < date.get(1)且end > date.get(0) - 說明:兩個條件都成立,代表新事件整個落在既有事件的範圍內。
5. 新事件把既有事件吞掉(更早開始、更晚結束)
New: |-----------|
Existing: |-----|
- 條件:
start < date.get(1)且end > date.get(0) - 說明:新事件比既有事件更早開始、更晚結束,把它整個蓋住。
拆解這個條件:
start < date.get(1):檢查新事件是不是在既有事件結束之前就開始了。end > date.get(0):檢查新事件是不是在既有事件開始之後才結束。
只要兩個條件同時成立就有重疊,函式回傳 false,表示這筆預約不能成立。
重疊判定式速查
範圍:這一節鎖定的是判定式(有沒有重疊?重疊多少?用哪一種慣例?)。 完整的區間演算法在別的文件:
intervals.md(合併/貪婪排程/雙指標求交集)、scanning_line.md(最大同時數、掃描事件)、difference_array.md(大量區間加值)。
0) 概念 — 兩種區間慣例
每一道區間題目都默默選了其中一種慣例。選錯會產生只在「端點剛好貼合」那個測資才爆出來的差一錯誤。
| 慣例 | 寫法 | 端點 a1 == b0 代表 |
題目常見的講法 |
|---|---|---|---|
閉區間 [a0, a1] |
頭尾都包含 | 算重疊(兩者共用 a1 這個點) |
「inclusive」、陣列索引 left..right、|x - y| <= t |
半開區間 [a0, a1) |
結尾不包含 | 不算重疊(首尾相接沒問題) | 「10 點結束的預約和 10 點開始的預約不衝突」 |
Closed: A = [1, 3] B = [3, 7]
|-----● ●-------| ● shared point 3 -> OVERLAP
Half-open: A = [1, 3) B = [3, 7)
|-----○ ●-------| ○ excluded -> NO OVERLAP
0-1) 標準重疊判定 Priority 5 of 5 — Must know — expect it in almost every loop
把這兩行背起來。區間題目其他所有東西都建在它們之上。
closed [a0, a1] ∩ [b0, b1] ≠ ∅ <=> a0 <= b1 && b0 <= a1
half-open [a0, a1) ∩ [b0, b1) ≠ ∅ <=> a0 < b1 && b0 < a1
為什麼是這種寫法(而不是分情況討論):因為它就是「不相交」的否定。
兩個區間不相交,若且唯若其中一個在另一個開始前就結束 —
a1 < b0 || b1 < a0(閉區間)/ a1 <= b0 || b1 <= a0(半開區間)。
對它做 De Morgan 就得到上面那兩行。這也是為什麼你根本不需要這份文件前面那 5 種情況的拆解 — 那 5 種情況會塌成同一個判定式。
面試時值得講出來的性質:
- 這個判定式是對稱的 — 參數誰先誰後都一樣。
- 它不需要排序。(如果區間本來就依開始時間排好,只要
b0 <= a1就夠了。) - 文件開頭那段
MyCalendar的start < date.get(1) && end > date.get(0)正是半開區間的寫法,只是兩個比較的順序對調而已。
0-2) 端點貼合 — 怎麼判斷題目要哪一種慣例
依序問下面這幾題,第一個有答案的就算數:
- 題目有沒有直接寫?「
[left, right)」、「end exclusive」、「half-open」→ 半開區間。 「inclusive」、「0 <= left <= right < n」→ 閉區間。 - 是預約/會議/時間軸類的題目嗎? 幾乎一定是半開區間 — 真實的行事曆本來就允許 09:00–10:00 的會議接著排 10:00–11:00。(LC 729 / 731 / 732 / 715 / 218 / 699。)
- 是陣列索引範圍,或是含端點的數值視窗嗎? 閉區間 — 端點要算進去,所以長度都會多一個
+ 1。(LC 303 / 304 / 307 / 220 / 1438 / 497 / 1348。) - 還是不確定?那就拿退化情況去問。 丟
(a, b)和(b, c)給面試官:「這兩個算不算衝突?」一句澄清就能消掉一整類差一錯誤。
正規化技巧(推薦):對整數來說,閉區間 [a, b] 和半開區間 [a, b + 1) 完全等價。在輸入時轉一次,之後全部都用半開區間的判定式。這樣就不用在合併/掃描/算長度的程式碼裡到處補 + 1。
0-3) 交集、聯集、長度與間隙的公式
intersection = [ max(a0, b0), min(a1, b1) ] # valid iff the overlap test passes
overlap length = max(0, min(a1, b1) - max(a0, b0)) # continuous / half-open measure
overlap int count = max(0, min(a1, b1) - max(a0, b0) + 1) # closed, counting integer points
union (bounding) = [ min(a0, b0), max(a1, b1) ] # a TRUE union only if they overlap/touch
gap (if disjoint) = max(0, max(a0, b0) - min(a1, b1))
陷阱:union 只有在兩個區間重疊或相接時才是真正的聯集。兩個不相交的區間丟進去,它會回傳外框,把中間那個洞悄悄吃掉 — 這正是你忘記加重疊防護時,合併迴圈會壞掉的原因。
陷阱:兩種長度公式混用。閉區間 [1, 3] 含有 3 個整數(1、2、3);半開區間 [1, 3) 的測度是 2。一題只選一種慣例,然後從頭到尾守住。
1) 一般形式
1-1) 基本操作 — 重疊判定工具箱
// java
// IDEA: canonical overlap / intersection / union predicates for 1D ranges.
// Closed [a0, a1] -> both ends INCLUDED, so "touching" (a1 == b0) IS an overlap
// Half-open [a0, a1) -> end EXCLUDED, so "touching" (a1 == b0) is NOT an overlap
public class RangeOps {
// time = O(1), space = O(1)
// closed [a0, a1] vs [b0, b1] -> touching endpoints count as overlap
static boolean overlapClosed(int a0, int a1, int b0, int b1) {
return a0 <= b1 && b0 <= a1;
}
// time = O(1), space = O(1)
// half-open [a0, a1) vs [b0, b1) -> touching endpoints are NOT an overlap
static boolean overlapHalfOpen(int a0, int a1, int b0, int b1) {
return a0 < b1 && b0 < a1;
}
// time = O(1), space = O(1)
// intersection range; meaningful only when the matching overlap test above is true
static int[] intersection(int a0, int a1, int b0, int b1) {
return new int[] { Math.max(a0, b0), Math.min(a1, b1) };
}
// time = O(1), space = O(1)
// overlap MEASURE for half-open / continuous ranges (0 when disjoint or only touching)
static int overlapLength(int a0, int a1, int b0, int b1) {
return Math.max(0, Math.min(a1, b1) - Math.max(a0, b0));
}
// time = O(1), space = O(1)
// overlap COUNT of integer points for closed ranges (note the "+ 1")
static int overlapCountClosed(int a0, int a1, int b0, int b1) {
return Math.max(0, Math.min(a1, b1) - Math.max(a0, b0) + 1);
}
// time = O(1), space = O(1)
// union bounding range; a true union only when the two ranges overlap or touch
static int[] union(int a0, int a1, int b0, int b1) {
return new int[] { Math.min(a0, b0), Math.max(a1, b1) };
}
// time = O(1), space = O(1)
// gap between two disjoint ranges (0 when they overlap or touch)
static int gap(int a0, int a1, int b0, int b1) {
return Math.max(0, Math.max(a0, b0) - Math.min(a1, b1));
}
}
# python
# IDEA: canonical overlap / intersection / union predicates for 1D ranges.
# Closed [a0, a1] -> both ends INCLUDED, so "touching" (a1 == b0) IS an overlap
# Half-open [a0, a1) -> end EXCLUDED, so "touching" (a1 == b0) is NOT an overlap
# time = O(1), space = O(1)
def overlap_closed(a0, a1, b0, b1):
return a0 <= b1 and b0 <= a1
# time = O(1), space = O(1)
def overlap_half_open(a0, a1, b0, b1):
return a0 < b1 and b0 < a1
# time = O(1), space = O(1)
# meaningful only when the matching overlap test above is true
def intersection(a0, a1, b0, b1):
return (max(a0, b0), min(a1, b1))
# time = O(1), space = O(1)
# continuous / half-open measure (0 when disjoint or only touching)
def overlap_length(a0, a1, b0, b1):
return max(0, min(a1, b1) - max(a0, b0))
# time = O(1), space = O(1)
# closed ranges: number of shared integer points (note the "+ 1")
def overlap_count_closed(a0, a1, b0, b1):
return max(0, min(a1, b1) - max(a0, b0) + 1)
# time = O(1), space = O(1)
# a TRUE union only when the two ranges overlap or touch
def union(a0, a1, b0, b1):
return (min(a0, b0), max(a1, b1))
# time = O(1), space = O(1)
def gap(a0, a1, b0, b1):
return max(0, max(a0, b0) - min(a1, b1))
1-2) 二維 — 矩形重疊就是把一維判定做兩次
核心想法:兩個軸對齊的矩形重疊,若且唯若它們的 x 範圍重疊「且」y 範圍也重疊。沒有另外一條二維公式要背 — 每個軸各自套用一維判定式就好。同樣的拆解方式可以推廣到任意維度。
// java
// IDEA: 2D overlap = AND of two independent 1D tests, one per axis.
// rect = [x1, y1, x2, y2] (bottom-left, top-right), half-open on both axes.
// time = O(1), space = O(1)
static boolean rectOverlap(int[] r, int[] s) {
return r[0] < s[2] && s[0] < r[2] // x-ranges overlap
&& r[1] < s[3] && s[1] < r[3]; // y-ranges overlap
}
// time = O(1), space = O(1)
// intersection area; 0 when the rectangles only touch along an edge or a corner
static long rectOverlapArea(int[] r, int[] s) {
long w = Math.max(0, Math.min(r[2], s[2]) - Math.max(r[0], s[0]));
long h = Math.max(0, Math.min(r[3], s[3]) - Math.max(r[1], s[1]));
return w * h;
}
# python
# IDEA: 2D overlap = AND of two independent 1D tests, one per axis.
# rect = (x1, y1, x2, y2) (bottom-left, top-right), half-open on both axes.
# time = O(1), space = O(1)
def rect_overlap(r, s):
return r[0] < s[2] and s[0] < r[2] \
and r[1] < s[3] and s[1] < r[3]
# time = O(1), space = O(1)
# 0 when the rectangles only touch along an edge or a corner
def rect_overlap_area(r, s):
w = max(0, min(r[2], s[2]) - max(r[0], s[0]))
h = max(0, min(r[3], s[3]) - max(r[1], s[1]))
return w * h
注意(LC 497):當一個矩形代表的是整數格點、而且含邊界時,它在兩個軸上都是閉區間,所以點的數量是 (x2 - x1 + 1) * (y2 - y1 + 1) — 不是面積 (x2 - x1) * (y2 - y1)。選錯會讓隨機取樣產生偏差。
2) LC 範例 — 每一題各需要哪一種判定式?
依題目強迫你採用的慣例分組。
半開區間 [start, end) — 允許端點貼合
| LC | 題目 | 需要的判定式 | 為什麼是半開 |
|---|---|---|---|
| 729 | My Calendar I | s < e2 && s2 < e |
20 點結束的預約和 20 點開始的預約不衝突 |
| 731 | My Calendar II | 同一個判定式,但允許重疊深度 ≤ 2 | 被禁止的是三重預約,不是端點相接 |
| 732 | My Calendar III | 所有點上的最大重疊深度 | 算 k 重預約,首尾相接不增加深度 |
| 715 | Range Module | 半開區間的重疊,但相接時要合併 | [1,3) 和 [3,5) 必須融合成 [1,5),queryRange 才會對 |
| 218 | The Skyline Problem | 每棟建築都是半開 | 共用同一條 x 邊的建築不算同時存在 |
| 699 | Falling Squares | [left, left + side) |
只在邊緣接觸的方塊不會疊起來 |
| 850 | Rectangle Area II | 二維半開(兩個軸取 AND) | 邊緣接觸貢獻 0 面積 |
閉區間 [start, end] — 端點要算,長度多一個 + 1
| LC | 題目 | 需要的判定式 | 為什麼是閉區間 |
|---|---|---|---|
| 1348 | Tweet Counts Per Frequency | t >= start && t <= end |
分塊邊界兩端都含 |
| 497 | Random Point in Non-overlapping Rectangles | 兩個軸都是閉區間 | 落在邊界上的點也是合法的取樣結果 |
| 303 | Range Sum Query - Immutable | 閉的索引範圍 [left, right] |
prefix[right + 1] - prefix[left] |
| 304 | Range Sum Query 2D - Immutable | 兩個軸都是閉區間 | 列/行的界都含端點;二維前綴和要補 + 1 位移 |
| 307 | Range Sum Query - Mutable | 閉的索引範圍 | 同樣是含端點的界,底層換成樹狀陣列/線段樹 |
| 220 | Contains Duplicate III | 數值視窗 [x - t, x + t],閉區間 |
|nums[i] - nums[j]| <= t 兩端都含 |
| 1438 | Longest Continuous Subarray With Absolute Diff ≤ Limit | 視窗 max - min <= limit,閉區間 |
limit 這個值本身是被允許的 |
2-1) MyCalendar 家族的變形筆記
文件開頭那段程式碼是深度 1 的情況。這個家族的擴展方式,就是把允許的重疊深度調大;判定式本身從來沒變過,變的只是你拿它來做什麼:
- LC 729(深度 1) — 新預約只要和任何一筆既有預約重疊就拒絕。
用半開判定式暴力做,每筆
O(n);改用 ordered map 是O(log n)。 - LC 731(深度 2) — 另外維護一份到目前為止所有交集的清單。只有當新預約和某個交集重疊時才拒絕。注意交集就是上面公式給的
[max(s, s2), min(e, e2)]。 - LC 732(最大深度) — 別再兩兩比對了;改成掃描:
start處+1、end處-1,一路追蹤最大值。到這裡,兩兩比對的判定式就撐不住了(O(n^2)→O(n log n))。
掃描線做法見 scanning_line.md,行事曆預約的模板見 intervals.md。
2-2) 參考題目
- LC 729 My Calendar I / LC 731 My Calendar II / LC 732 My Calendar III — 預約深度 1 / 2 / 最大值
- LC 715 Range Module — 半開區間,相接時必須合併
- LC 218 The Skyline Problem / LC 699 Falling Squares — 在 ordered set 上做半開區間掃描
- LC 850 Rectangle Area II — 用每軸的一維判定式湊出二維重疊
- LC 497 Random Point in Non-overlapping Rectangles — 閉的格點,數量是
(x2-x1+1)*(y2-y1+1) - LC 1348 Tweet Counts Per Frequency — 閉的時間分塊
- LC 303 / 304 / 307 Range Sum Query (Immutable / 2D / Mutable) — 閉的索引範圍
- LC 220 Contains Duplicate III / LC 1438 Longest Continuous Subarray With Absolute Diff ≤ Limit — 閉的數值視窗