在 Windows 上完成列表逻辑后,把 Swift 代码放到云端 Mac 验收,最容易漏掉的不是“能否找到元素”,而是重复值该插在哪里。聊天时间轴、分段刻度和有序记录索引都可能依赖二分查找;位置错一格,普通样本仍然通过,边缘输入却会丢记录。下面用独立脚本建立可执行契约,不需要启动模拟器,也不依赖网络数据。
先定义两个插入边界
假定输入是升序整数数组。下界返回第一个“大于或等于目标”的位置,上界返回第一个“严格大于目标”的位置。二者相减,就是目标的出现次数。
例如数组 [1, 2, 2, 4] 查找 2,下界是 1,上界是 3。查找 5,两者都返回 4,表示末尾插入,而不是查找失败。
返回位置可以等于数组长度,但读取下标不能。先检查范围,再读取元素,不能用最后一个元素替代末尾插入位置。
这里不使用 -1 哨兵,也不让函数自动排序。自动排序会掩盖调用方传入乱序数据的问题,还可能破坏记录之间原有的顺序关系。
用半开区间实现查找
登录云端 Mac 后,在自己的工作目录创建 BoundsCheck.swift。先运行 xcrun --find swift 确认工具链可用,再写入以下实现。搜索区间始终是 [lo, hi),初始上界为数组长度。
func lowerBound(_ values: [Int], _ target: Int) -> Int {
var lo = 0
var hi = values.count
while lo < hi {
let mid = lo + (hi - lo) / 2
if values[mid] < target {
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
func upperBound(_ values: [Int], _ target: Int) -> Int {
var lo = 0
var hi = values.count
while lo < hi {
let mid = lo + (hi - lo) / 2
if values[mid] <= target {
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
两个函数只有比较条件不同,但这个差异决定重复值的归属。中点写成 lo + (hi - lo) / 2,避免直接相加。每轮必须缩小区间,不能将左边界更新成 mid,否则双元素输入可能无法结束。
空数组无需特殊分支:循环不执行,直接返回零。不要为了处理空数组引入 count - 1,那会重新带来负下标和闭区间终止条件。
用线性参考验收全部小样本
参考实现应该简单,而不是另一份二分查找。把下面代码追加到同一个文件,枚举长度零到六、元素取值零到三的全部非递减数组,再检查范围内外的目标。
func verify(_ values: [Int]) {
for target in -1...4 {
let lower = lowerBound(values, target)
let upper = upperBound(values, target)
let expectedLower =
values.firstIndex(where: { $0 >= target }) ?? values.count
let expectedUpper =
values.firstIndex(where: { $0 > target }) ?? values.count
precondition(lower == expectedLower)
precondition(upper == expectedUpper)
precondition(lower <= upper)
precondition(values[..<lower].allSatisfy { $0 < target })
precondition(values[lower...].allSatisfy { $0 >= target })
precondition(values[..<upper].allSatisfy { $0 <= target })
precondition(values[upper...].allSatisfy { $0 > target })
}
}
func enumerate(_ prefix: [Int], remaining: Int, minimum: Int) {
if remaining == 0 {
verify(prefix)
return
}
for value in minimum...3 {
enumerate(prefix + [value],
remaining: remaining - 1,
minimum: value)
}
}
for length in 0...6 {
enumerate([], remaining: length, minimum: 0)
}
verify([Int.min, 0, Int.max])
precondition(lowerBound([Int.min, 0, Int.max], Int.min) == 0)
precondition(upperBound([Int.min, 0, Int.max], Int.max) == 3)
执行 xcrun swift BoundsCheck.swift,退出码为零表示断言全部通过;失败则进程以非零状态结束。脚本不使用可能在优化构建中被移除的普通 assert,也不要使用关闭安全检查的编译选项。
把结果接回业务接口
查找元素是否存在时,先取下界,只有位置小于长度且对应值等于目标,才算命中。获取重复值集合时,使用 lower..<upper 切片;目标不存在时自然得到空切片。
保持排序与比较一致
如果数组存储记录,排序依据是时间戳,那么查找也必须使用相同时间戳语义。排序时按时间升序、查找时却比较字符串展示值,算法本身正确也无济于事。
相同时间戳需要保留到达顺序时,可选择上界插入。若还有业务优先级,应先定义完整排序键,而不是指望插入位置替代排序规则。降序数组不能直接复用这里的比较条件。
查找期间还应保持数组不变。异步读取位置后再修改数组,旧位置可能已经失效;应在同一隔离域内完成查找和插入,或针对同一份不可变快照操作。
用失败样本形成回归清单
接入项目测试时保留三层检查:固定业务案例、小数组穷举、真实记录的排序契约。不要只判断返回值位于合法范围,范围合法仍可能偏移一个位置。
- 覆盖空数组、单元素、全部相同、目标位于两端之外。
- 检查下界左侧全部小于目标,上界右侧全部大于目标。
- 检查重复区间长度等于线性统计次数。
- 将失败数组、目标与预期位置保存为固定案例。
- 在实际使用的工具链与编译配置下重新执行。
穷举小样本不等于证明所有整数输入正确,但能系统覆盖重复值与区间收缩路径。后续即使改成泛型比较器,也应保留线性参考和分区断言,让优化建立在不变的接口契约上。
常见问题
查找不到元素时应该返回什么?
边界查找返回合法插入位置,范围是 0 到数组长度,数组长度本身也是有效结果。判断元素是否存在,需要先确认位置小于数组长度,再比较元素。
重复值应该插在已有元素之前还是之后?
lower bound 返回第一个不小于目标的位置,适合插在重复值之前;upper bound 返回第一个大于目标的位置,适合插在重复值之后。