学习指南 →

· Albion Gateway College

二分查找为什么不会漏掉答案?从循环不变式检查边界

用含重复值的原创数组逐步追踪二分查找,证明左闭右开区间的更新规则,区分插入位置与查找成功,并练习空数组和末端边界。

先把任务改写成一个精确问题

二分查找容易写出能通过几个例子的程序,却在重复值或空数组上失效。原因通常不是没有记住“每次减半”,而是没有定义返回值。本文要找的是:在非降序数组中,第一个大于或等于目标值的位置。如果所有元素都小于目标,返回数组长度。这个位置也就是一种左侧插入点。

设练习数组为 [3, 7, 7, 12, 18, 24],目标为 7。正确答案是下标 1,而不是任意一个值为 7 的位置。下标从零开始。先确定重复元素的处理规则,后续的分支和测试才有同一个判断标准。所有数组和操作均为原创教学设定。

区间外已经确定,区间内继续判断

令 n 为长度,初始 lo=0、hi=n,待检查的元素区间写作 [lo, hi),即包含 lo、不包含 hi。保持两个陈述:所有下标小于 lo 的元素都小于目标;所有下标大于或等于 hi 且小于 n 的元素都大于或等于目标。空的左右区域自然满足这些陈述。

这两个陈述就是本题的循环不变式。它们不是说答案一定是某个尚未检查的元素,因为答案也可能为 n;更准确地说,分界位置始终处于闭区间 [lo, hi]。区分“待检查的元素”和“可能的分界位置”,能解释为什么 hi 可以等于长度而不会被直接读取。

两条更新规则分别保留什么事实

当 lo<hi 时,取 mid=lo+(hi−lo)//2。若 a[mid]<目标,由于数组有序,从 lo 到 mid 的值都小于目标,因此令 lo=mid+1。mid 已经被排除,不能继续保留它。若 a[mid]≥目标,则 mid 及右侧都可归入已确定的右区,令 hi=mid。

不必单独设置“相等时立即返回”的分支,因为这样可能返回重复值中间的位置。这里把相等归入收紧右界的情况,继续寻找更靠左的分界。两条更新都维持不变式,而且让非负整数 hi−lo 严格减少,这同时给出不会无限循环的理由。

完整追踪:为什么最后停在下标一

第一次 lo=0、hi=6,mid=3,a[3]=12,大于或等于 7,于是 hi=3。第二次 lo=0、hi=3,mid=1,a[1]=7,仍令 hi=1。第三次 lo=0、hi=1,mid=0,a[0]=3,小于 7,于是 lo=1。现在 lo=hi=1,循环结束。

终止时,不变式说明下标 1 左边都小于 7,下标 1 起都大于或等于 7。因此返回 1 有证明支撑。把每轮写成“旧边界、mid、比较结果、新边界”四列,比只看最后输出更容易找出错误;若某轮新旧边界完全一样,就应先检查更新式。

插入点不等于已经找到目标

如果目标改为 10,分界位置是 3,因为 a[3]=12 是第一个大于或等于 10 的值,但数组没有 10。要实现精确查找,应先取得位置 i,再检查 i<n 且 a[i]==目标。必须先检查 i<n,否则目标大于所有元素时会访问越界。

Python 官方 bisect_left 文档给出的就是左侧插入位置语义。本练习中的整数条件与其一致;调用库函数时仍要知道契约,而不能把返回的非负整数直接当作成功标记。若要统计目标出现几次,可分别取得右插入点和左插入点,用两者之差计算。

自测一:边界变化需要自己写完

对同一个数组分别查找 2、7、25,先预测插入点,再逐轮追踪。答案依次为 0、1、6。对于 25,边界依次由 (0,6) 变为 (4,6)、(6,6);返回 6 是有效的插入位置,但不是有效的元素下标。空数组的初始状态是 (0,0),无需比较即返回 0。

再考虑仅有一个元素的 [7]。目标为 7 时,mid=0,更新 hi=0 后结束;目标为 8 时,更新 lo=1 后结束。如果把“小于”的分支误写为 lo=mid,查找 8 就会永远停在 (0,1)。这比在大数组里随机运行更直接地暴露了不终止的问题。

自测二:为什么不能随意换一个符号

有人把比较条件改成 a[mid]≤目标,其他更新保持不变。它对目标 7 返回什么?答案为 3,因为现在左侧被确定为“小于或等于目标”,右侧为“严格大于目标”,得到的是右侧插入点。这个版本本身可以正确,但它解决了不同的问题。

再有人把 hi=mid 改为 hi=mid−1,却仍使用 [lo,hi) 的说明。这会提前排除尚可能是答案的边界。例如 [3,7] 查找 7,第一次 mid=1 后 hi=0,最终返回 0,明显错误。闭区间算法也能成立,但初始化、循环条件、更新和返回逻辑必须成套推导。

适用前提与复习方法

本方法要求数组按同一种比较规则有序,且查找过程中不被修改。比较不具备一致顺序、数据含特殊的不可比较值,或者排序键与查找键不一致时,前述证明不能直接使用。对普通随机访问数组,比较次数随长度对数增长;若每次访问中间元素本身很昂贵,总成本要另外计算。

用二分找到插入点,并不意味着向数组插入元素也是对数成本;移动后续元素通常仍需线性时间。复习时先口头解释不变式,再手算一组重复值、一组缺失目标和一个空数组,最后才写代码。能解释为何保留 mid 或排除 mid,比背诵两行更新更能迁移到新题。

可以再用一个反例检验“有序”不是可省略的装饰条件。数组换成 [3,12,7,18],目标仍为 10,第一次中点读到 7,算法把左界移到下标 3,随后返回 3,却漏掉了下标 1 的 12。更新操作并没有写错,错的是输入不满足证明前提。调试时应分别检查输入契约与算法实现,不要只修改边界直到这个例子碰巧通过;若每次查找前都重新排序,还要把排序成本和原始下标变化计入设计。

参考资料

下一步

如想练习算法证明与边界调试,请说明计算机模块、使用语言及自己已经写出的查找过程。相关辅导能否安排,取决于学科匹配和导师时间。

继续阅读

学院在线咨询

欢迎咨询

你好,欢迎咨询!你是学生、招生代理、求职者,还是想申请成为导师?请告诉我你的咨询方向。

[email protected]