雲端 Mac 上的 iOS 預約衝突檢測:半開區間與掃描線驗收
預約清單顯示兩場會議前後相接,送出時卻提示衝突;另一筆紀錄完整涵蓋三場較短的預約,統計頁卻只顯示一場同時占用。這兩種問題都不必先從介面查起。在雲端 Mac 上抽出純 Swift 區間函式,固定邊界條件與參考答案,就能將錯誤縮小為可重現的輸入。以下處理的是單一資源的占用時間,不涉及日期顯示或網路請求。
先確立半開區間契約
將每筆預約表示為 [start, end):包含起點,不包含終點。因此 [0, 10) 與 [10, 20) 不衝突,而 [0, 10) 與 [9, 20) 會衝突。時間刻度可以用秒或分鐘,但同一次計算必須統一單位,不能直接混用兩種精度。
輸入端應拒絕結束時間不晚於開始時間的資料。零時長提醒不屬於占用資源的預約,應使用另一種資料模型;不要為了讓它參與排序,就擅自補上一秒。
「合併占用範圍」與「計算最大同時占用數」是兩個問題。合併會丟失重疊層數,不能先合併再計算同時占用數。
如果業務規則要求兩場預約之間保留準備時間,應先將準備時間納入占用區間,再套用同一套規則。不要只在介面層額外判斷,否則清單與提交介面會得出不同答案。
寫出可獨立執行的核心實作
將以下內容儲存為 Intervals.swift。程式碼只使用標準函式庫,合併時只連接真正重疊的區間,不將前後相接視為重疊。這裡採用整數刻度,是為了先將演算法與日期轉換分開處理。
typealias Slot = Range<Int>
func merged(_ input: [Slot]) -> [Slot] {
precondition(input.allSatisfy { !$0.isEmpty })
let sorted = input.sorted {
if $0.lowerBound == $1.lowerBound {
return $0.upperBound < $1.upperBound
}
return $0.lowerBound < $1.lowerBound
}
var result: [Slot] = []
for slot in sorted {
if let last = result.last,
slot.lowerBound < last.upperBound {
result[result.count - 1] =
last.lowerBound..<max(last.upperBound, slot.upperBound)
} else {
result.append(slot)
}
}
return result
}
func peak(_ input: [Slot]) -> Int {
precondition(input.allSatisfy { !$0.isEmpty })
let events = input.flatMap {
[(time: $0.lowerBound, delta: 1),
(time: $0.upperBound, delta: -1)]
}.sorted {
if $0.time == $1.time {
return $0.delta < $1.delta
}
return $0.time < $1.time
}
var active = 0
var maximum = 0
for event in events {
active += event.delta
maximum = max(maximum, active)
}
return maximum
}
precondition(merged([]).isEmpty)
precondition(peak([]) == 0)
precondition(merged([0..<10, 10..<20]) == [0..<10, 10..<20])
precondition(peak([0..<10, 10..<20]) == 1)
precondition(merged([0..<10, 5..<15, 10..<20]) == [0..<20])
precondition(peak([0..<10, 5..<15, 10..<20]) == 2)
precondition(merged([0..<30, 5..<10, 8..<12]) == [0..<30])
precondition(peak([0..<30, 5..<10, 8..<12]) == 3)
precondition(peak([0..<10, 0..<10]) == 2)
在終端機執行:
xcrun swift Intervals.swift
通過時不會產生輸出,結束碼為零。使用 precondition,而非只用 assert,可避免一般最佳化建置停用斷言後,測試形同虛設;驗收腳本也不應採用停用安全檢查的編譯選項。
驗收同時刻事件與巢狀區間
掃描線將開始事件記為加一、結束事件記為減一。同一時刻必須先減後加,否則兩場相鄰預約會在計算過程中短暫被算成兩場同時占用。只按時間排序、未明確指定同時刻事件的順序,是常見的邊界漏洞。
至少保留五類固定測試案例:空清單、前後相接、部分重疊、完整包含、完全重複。重複區間是否代表兩筆獨立預約,取決於業務輸入;本文將每個元素視為一筆占用,因此兩筆完全重複的預約,同時占用數為二。
合併演算法還有一個容易遺漏的細節:更新右端點時,必須取較大值。若直接覆寫為目前區間的結束時間,長預約包含短預約時,合併結果反而會縮短。輸入順序打亂後也應得到相同結果,不能仰賴伺服器剛好已經排好序。
透過另一條計算路徑交叉檢查
固定案例的預期答案,也可能跟著實作一起「寫錯」。對範圍較小的整數案例,可以逐一計算每個刻度落在多少個區間內,作為獨立參考。將下列程式碼附加到同一個檔案即可執行;它僅供測試,不取代正式環境的演算法。
func referencePeak(_ input: [Slot]) -> Int {
guard let start = input.map(\.lowerBound).min(),
let end = input.map(\.upperBound).max() else {
return 0
}
return (start..<end).map { tick in
input.filter { $0.contains(tick) }.count
}.max() ?? 0
}
let candidates: [Slot] = (0..<5).flatMap { start in
((start + 1)...5).map { start..<$0 }
}
for first in candidates {
for second in candidates {
let sample = [first, second]
precondition(peak(sample) == referencePeak(sample))
precondition(merged(sample) ==
merged(Array(sample.reversed())))
precondition(merged(merged(sample)) == merged(sample))
}
}
這些檢查分別驗證同時占用數的正確性、結果與輸入順序無關,以及合併的冪等性。這個離散參考演算法只適用於此處的整數刻度契約,不能據此宣稱已涵蓋任意連續時間。若加入隨機測試,應儲存失敗時的輸入,而不只是記錄隨機種子,方便工具鏈變更後重現問題。
整合至專案前,嚴格驗證輸入並保留失敗證據
正式整合時,將解析、正規化與區間計算分成三層。先取得同一時間基準下的起迄值,再統一精度,最後檢查結束時間是否嚴格晚於開始時間。精度轉換後,原本很短的預約可能變成零時長,因此不能只在轉換前驗證。
若多個房間共用預約清單,應先依資源識別碼分組,再各自計算;不同房間同時開會,不應彼此衝突。若人員也不能同時參與不同預約,應另建一組人員資源約束,不要無條件將所有預約混進同一條掃描線。
失敗紀錄只需保留資源識別碼、正規化後的區間、預期值、實際值與工具鏈版本,不必附上與會者資訊。修正前,先將該筆輸入加入固定測試案例,再執行完整腳本。最終通過條件應是邊界案例、參考計算與業務分組的結果全部一致,而不只是某個介面不再跳出衝突提示。
常見問題
上一筆預約結束時下一筆開始,算衝突嗎?
本文採用半開區間 [start, end),因此不算衝突。同一時刻先處理結束事件,再處理開始事件;若需要準備時間,應先擴展占用區間再檢測。
合併後的區間數量能代表最大同時預約數嗎?
不能。合併結果保留占用時間範圍,但會失去重疊層數。最大同時數必須從原始區間產生開始與結束事件,再以掃描線計算。
為什麼先以整數時間驗收?
整數刻度能將區間算法與日期解析分開。本番輸入仍須統一時間基準與精度,並在轉換後拒絕結束不晚於開始的區間。