预约列表显示两场会议首尾相接,提交时却提示冲突;另一条记录完整包住三场短预约,统计页又只显示一场并发。这两种故障都不需要先查界面。在云端 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),因此不算冲突。同一时刻先处理结束事件,再处理开始事件;如果业务需要准备时间,应先扩展占用区间再检测。
合并后的区间数量能代表最大并发数吗?
不能。合并结果表示被占用的时间范围,会丢失重叠层数。最大并发数应对原始区间生成开始与结束事件,再用扫描线计算。
为什么先使用整数时间而不是直接解析日期?
整数刻度可以隔离区间算法与日期解析问题。生产入口仍需把时间统一成相同基准与精度,并在转换完成后拒绝结束不晚于开始的输入。