想象你是电影院的引座员,手里只有一支手电筒。今晚有一排 20 个座位,编号从 0 到 19,七个素不相识、却都买了 3 号票的观众坐在这排的连座上。你接到两个任务:先找到最左边那位 3 号观众,再找到最右边那位 3 号观众。挨个座位照过去当然可以,但电影马上开场,太慢了。聪明的做法是:先照正中间的座位,根据座位上的人告诉你“大了”还是“小了”,一次性排除一半座位。这就是二分搜索。可问题来了:当你照到一位 3 号观众时,你敢说这就是最左边的吗?不一定——左边可能还坐着三号。于是你需要两种“边界变体”:一种向左追到第一个三号,一种向右追到最后一个三号。这个看似微小的改动,背后藏着区间收缩、终止条件和死循环的整套学问。本文就用这支手电筒,带你把二分搜索的边界问题看得明明白白。
二分边界变体是算法面试与工程检索的高频考点,掌握它就能在索引、范围查询与排序数据统计中写出 O(log n) 的高效代码,是算法工程师的必备基本功。
可视化演示
二分搜索的边界变体包括查找左边界和右边界,用于在有序数组中找到目标值的首次和末次出现位置。本文讲解左边界查找的... 切换模式查看不同维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
一、标准二分:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现
标准二分基础
引座员第一次拿起手电筒,最聪明的策略不是从 0 号座位挨个照,而是先照正中间。如果中间那位观众说自己是 2 号、比 3 号小,那么他左边所有的座位都不可能是 3 号了——因为这一排座位号从左到右递增。一次照射排除一半,这就是二分搜索的原型。它有一个大前提:座位号必须有序,否则“中间比目标小”根本推不出“左边全都小”。
在算法术语里,“这一排座位”叫有序数组,座位编号叫下标,要找的票号叫目标值 target。每次取当前区间 [L, R] 的中点 mid,比较 arr[mid] 与 target:中间值太小就舍弃左半边,太大就舍弃右半边。区间每轮缩小一半,n 个元素最多查 log₂n 次,这就是对数时间复杂度的由来。整个过程的循环不变量是:“只要 target 存在,它就一定还在当前 [L, R] 区间里”——每次收缩都必须守住这句话,手电筒才不会照丢人。
1. 计算区间跨度:用 R - L 求出当前待查区间的间隔数,比如 L=0、R=9 时跨度是 9。
2. 跨度折半:把跨度除以 2,得到区间一半的长度,这是“折半”思想的数学表达。
3. 向下取整:用 floor 抹掉小数,保证 mid 是合法的整数下标。
4. 偏移回中点:把相对偏移加回左端点 L,变成数组里真实的中间位置下标。
手电筒一次照亮一半,这就是标准二分。但它只能回答“有没有”这个是非题,回答不了“第一个在哪儿、最后一个在哪儿”。于是引座员必须学会两种更精细的照法:一种向左追,一种向右追。
左边界查找
引座员的新任务是找到最左边的 3 号。他照到中间座位,发现是 3 号,但他不敢停——左边可能还坐着别的 3 号。于是他把照射范围的右端收回到 mid 的左侧(R = mid - 1),继续向左追。每一次照到 3 号都往左收一次,直到光圈彻底空掉。这个过程就是“左边界查找”,也叫 lower_bound。
规则只有一句话:只要 arr[mid] >= target,就把右边界收缩到 mid - 1;否则(arr[mid] < target)把左边界收缩到 mid + 1。注意“等于也要向左收”,这正是它与标准二分最大的区别——标准二分见到相等就 return,而左边界查找见到相等反而要逼自己往左看,因为左边可能存在更靠前的目标。收缩过程中,循环不变量依然是“第一个目标一定在当前区间内”,只是现在的判断标准从“有没有”变成了“第一个在哪”。
1. 循环条件:L <= R 表示手电筒光圈里还有候选座位,就继续照。
2. 取中点:和标准二分一样求 mid,这是每次照射的落点。
3. 相等或偏大向左收:arr[mid] >= t,说明第一个 t 不可能在 mid 右边,把右端收到 mid 左侧。
4. 偏小向右收:arr[mid] < t,说明目标只可能在右边,把左端收到 mid 右侧。
5. 返回左指针:退出时 L 正是第一个 >= t 的位置,即左边界。
演示图中上排完整走了一遍这个过程:arr[mid]=3 时不返回,而是 R=3、再 R=1,最终 L=2 停在第一个 3 号面前。引座员总结出一条经验:找“第一个”,就要做到“见到目标也贪心,继续把右端往左赶”。
右边界查找
现在任务反过来:找最右边的 3 号。引座员把规则镜像翻转——照到 3 号,不往左收,而是把左端收到 mid 右侧(L = mid + 1),继续向右追;只有照到大于 3 号的座位,才把右端收回来。这套对称的追法就是“右边界查找”,也叫 upper_bound。
对称体现在方方面面:左边界“等值收缩右端”,右边界“等值收缩左端”;左边界返回 L,右边界返回 R。具体规则是:arr[mid] <= target 时 L = mid + 1;arr[mid] > target 时 R = mid - 1。循环退出时 R 停在最后一个 <= target 的位置,也就是 target 的末次出现位置(若 target 存在)。这里要先埋一个伏笔:如果为了代码好看而把收缩写成 L = mid 而不是 L = mid + 1,mid 就必须向上取整,否则区间只剩两个元素时会陷入死循环,我们留到“死循环防范”一节再拆解。
1. 相等或偏小向右收:arr[mid] <= t,说明最后一个 t 不可能在 mid 左边,把左端收到 mid 右侧。
2. 偏大向左收:arr[mid] > t,说明目标在左边,把右端收到 mid 左侧。
3. 返回右指针:退出时 R 恰好是最后一个 <= t 的位置,即右边界。
回到演示图的下排:照到 3 号不满足,L 一路从 0 推到 7,最终 R=6 停在最后一个 3 号面前。引座员满意地点点头:左边界返回 L、右边界返回 R,两只指针之间夹着的,正是那七个连坐的 3 号观众。
区间写法对比
手电筒的光圈有两种标记法。第一种是“两端都照”:范围 [L, R] 左右端点都包含在候选里;第二种是“右端只照到门口”:[L, R) 左端包含、右端不包含。同样是找左边界,两种照法写出来的代码并不一样——这常常是初学者最容易搞混的地方。
左闭右闭([L, R])的写法是:while (L <= R),收缩时 R = mid - 1、L = mid + 1。原因是 mid 已经被比较过,必须排除出候选。左闭右开([L, R))的写法是:while (L < R),收缩时 R = mid、L = mid + 1。原因是 R 本身是开区间端点、不参与比较,收缩时直接把它拉到 mid 即可,不用再减一;退出时 L == R,这个位置就是答案。一句话记忆:闭区间“比较过的就排除”,开区间“右端本来就是排除区,收进来不用减一”。
1. 开区间语义:[L, R) 中 R 是“已排除区间的起始点”,它本身不参与候选,只负责划定边界。
2. 退出条件不同:区间缩到只剩一个元素时 L == R,循环结束,答案就是 L。
3. 右端收缩:因为 R 不参与比较,收缩时把 R 拉到 mid 即可,不需要 mid - 1。
4. 左端收缩:L 参与比较,收缩时必须排除 mid,所以是 mid + 1。
引座员恍然大悟:光圈“闭哪一端”决定了语法里的一切细节。只要想清楚“哪端包含、哪端排除”,无论左闭右闭还是左闭右开,都能稳稳找到第一个 3 号。真正重要的不是背模板,而是理解区间语义。
应用场景
引座员发现边界二分的用途远不止“找人”。有新人进场问“我该坐哪个空位”,这需要查找插入位置;经理问“这一排到底坐了几个三号观众”,这需要统计出现次数;还有观众问“3 号到 4 号之间坐了哪些人”,这需要范围查询。三个问题,同一支手电筒。
第一个场景是查找插入位置:在有序数组中插入新元素并保持有序,lower_bound(t) 返回的 L 就是新元素该放的下标,把原位置的元素整体右移即可。第二个场景是范围查询:想知道值落在 [a, b] 区间的所有元素,用 lower_bound(a) 得到起点,用 upper_bound(b) 得到终点,两个下标之间的连续切片就是答案。第三个场景是统计个数:count = upper_bound(t) - lower_bound(t) + 1;如果 lower 大于 upper,说明数组里根本没有 t,个数为 0。数据库索引的范围扫描、特征工程里的区间统计、日志按时间戳截取,全都在用这套逻辑。
1. 定位左界:lower = lower_bound(t) 返回第一个 >= t 的下标,作为区间起点。
2. 定位右界:upper = upper_bound(t) 返回最后一个 <= t 的下标,作为区间终点。
3. 计算长度:闭区间元素个数 = 终点 - 起点 + 1。
4. 空集判定:当 lower > upper 时表示区间为空,个数记 0。
引座员把台词默念一遍:找第一个、找最后一个、找插入位、数个数,全是同一支手电筒的四种用法。掌握一支手电筒,就等于同时掌握了四样本领。
死循环防范
手电筒最怕什么?怕每轮明明“动了”,光圈却根本没变小。传说中有位粗心的引座员,为了追求左右边界的代码对称,把收缩规则改成了 L = mid。结果遇到只剩两个候选座位时,mid 向下取整永远等于左端,光圈卡在两个座位之间来回横跳,电影散场了还没找到人。
死循环的根因就是“区间没有必然收缩”。在 while (L <= R) 的框架下,如果收缩规则允许 L 或 R 取到 mid,而当 R = L + 1、mid 向下取整时,mid = L,一旦走进 L = mid 的分支,左端纹丝不动,区间长度永远是 2,程序就永远转圈。防范办法有三:第一,优先写 L = mid + 1 / R = mid - 1,保证每轮至少排除一个元素;第二,如果非要写 L = mid,就必须让 mid 向上取整,即 mid = (L + R + 1) // 2;第三,在函数入口检查空数组和 target 越界,避免返回 -1 或 len(arr) 这类非法下标。最后别忘了用边界用例测试:空数组、全部相等、目标在两端、目标不存在,四个用例各跑一遍,代码才敢上线。
1. 陷阱现场:当区间只剩两个元素(R = L + 1)且收缩规则是 L = mid 时,向下取整会让 mid = L,左端永远不动。
2. 向上取整:把折半方式改成 ceil,mid 会取到 R,区间必然向左收缩。
3. 等价整数写法:ceil((R-L)/2) 等于 floor((R-L+1)/2),便于用整除实现。
引座员终于顿悟:二分查找的命门不是“聪明”,而是“每轮都必须缩小范围”。只要守住这条铁律,手电筒的每一次闪烁都离真相更近一步;守不住,再亮的灯也只能在原地打转。
AI 应用场景
数据库索引的范围查询
阶段:数据检索 / 查询优化
解决的问题:训练数据预处理时经常要按特征值范围筛选样本,比如“年龄在 20 到 30 岁之间的用户”。传统全表扫描是 O(n),在亿级样本上慢得不可接受。利用有序索引做边界二分,先 lower_bound(20) 拿到起点、upper_bound(30) 拿到终点,O(log n) 就能锁定样本区间直接切片。
具体案例:训练用户画像模型时,要从每天上千万条日志中提取“停留时长在 3 到 60 秒”的样本。工程师先把日志按停留时长排序,再用两个边界二分锁住区间,一次查询从秒级降到微秒级,整个预处理流水线提速数百倍。
特征分布的快速统计
阶段:特征工程 / 统计分析
解决的问题:分析特征分布需要知道每个分桶里有多少样本(直方图)。逐条遍历统计是 O(n),每次调参都要重跑一遍。把特征列排序后用边界二分,每次区间统计只要 O(log n)。
具体案例:算法工程师在做模型置信度校准,需要统计预测分数落在 0.7 到 0.9 区间的样本量。分数数组排好序后,两次二分拿到上下界,代码三行,速度提升几个数量级,调参迭代再也不卡在统计上了。
日志时间窗口截取
阶段:数据预处理 / 可观测性
解决的问题:日志按时间戳有序存储,要定位某次异常事件前后的日志窗口。线性扫描浪费时间,边界二分能直接找到窗口首尾下标,原地截取。
具体案例:MLOps 工程师排查一次模型推理异常,需要取出 14:32:05 到 14:32:35 之间所有推理日志。用 lower_bound 和 upper_bound 对时间戳数组二分,瞬间拿到起止下标,再配合分布式存储的区间读,秒级定位故障现场。
Python 演示
def lower_bound(arr, target): """返回第一个 >= target 的下标(左边界, 也为插入位置)""" left, right = 0, len(arr) - 1 while left <= right: mid = left + (right - left) // 2 if arr[mid] >= target: right = mid - 1 else: left = mid + 1 return left def upper_bound(arr, target): """返回最后一个 <= target 的下标(右边界)""" left, right = 0, len(arr) - 1 while left <= right: mid = left + (right - left) // 2 if arr[mid] <= target: left = mid + 1 else: right = mid - 1 return right def count_in_range(arr, low, high): """统计值落在闭区间 [low, high] 内的元素个数""" if not arr: return 0 lo = lower_bound(arr, low) hi = upper_bound(arr, high) return max(0, hi - lo + 1) def main(): arr = [1, 2, 3, 3, 3, 3, 3, 4, 5, 6] for t in [3, 0, 9]: lb = lower_bound(arr, t) ub = upper_bound(arr, t) if lb <= ub and lb < len(arr) and arr[lb] == t: print("target={}: count={}, range=[{},{}]".format(t, ub - lb + 1, lb, ub)) else: print("target={}: not found, insert at {}".format(t, lb)) print("count in [3,4]:", count_in_range(arr, 3, 4)) print("count in [7,9]:", count_in_range(arr, 7, 9)) print("empty array lower_bound(1):", lower_bound([], 1)) if __name__ == "__main__": main()
if arr[mid] >= target:左边界的关键判断:等值也不停,继续向左收。right = mid - 1把右端收到 mid 左侧,排除已比较过的元素。if arr[mid] <= target:右边界的关键判断:等值继续向右追。left = mid + 1把左端推到 mid 右侧,保证区间每轮收缩。mid = left + (right - left) // 2防溢出的中点写法,等价于 (left+right)//2 但更安全。if lb <= ub and lb < len(arr) and arr[lb] == t:用边界关系判断 target 是否存在,比“返回 -1”语义更丰富。max(0, hi - lo + 1)空区间保护,避免 lower 大于 upper 时算出负数。
常见误区
误区我写二分查找时,只要 `arr[mid] == target` 就直接 `return mid`,这才是标准写法,边界变体为什么不行?
正解标准二分的“相等即返回”只能回答“有没有”,而边界变体要的是“第一个/最后一个”。引座员看到 3 号座位不能停,必须继续确认左边或右边还有没有下一个 3 号。正确做法是把“相等”并入“继续收缩”的分支:左边界并入 >=、右边界并入 <=,直到区间为空才返回指针。
为何常见:教材里的经典二分模板就是“三路分支 + 相等返回”,初学者惯性太强,容易直接把模板套到边界问题上。
误区lower_bound 找不到 target 就应该返回 -1,否则代码就是错的,别的语言里 find 不都是返回 -1 吗?
正解返回 -1 会丢失关键信息。lower_bound 返回的 L 是“第一个 >= target 的位置”,它同时就是插入位置。判断是否存在只需检查 L < len(arr) and arr[L] == target。这就像引座员虽然没找到 3 号,但能准确告诉新人“你应该坐在 5 号位”——这比一句“没有”有用得多。
为何常见:很多编程语言的内置查找接口用 -1 表示未找到,先入为主的印象让初学者忽略了“插入位置”这个更丰富的语义。
误区右边界查找就是左边界的代码把符号对调,收缩写 L = mid 也没问题,反正都是二分。
正解如果收缩写成 L = mid,而 mid = (L + R) // 2 向下取整,当区间只剩两个元素(R = L + 1)时 mid 恒等于 L,左端永远不动,直接死循环。要么坚持 L = mid + 1(可向下取整),要么 L = mid 时改成 mid = (L + R + 1) // 2 向上取整。就像手电筒每次必须往前挪一格,不能总照同一张座椅。
为何常见:死循环只在“区间差 1 + 特定分支”同时出现时才触发,常规测试用例很难覆盖到,导致问题隐蔽。
误区左闭右开 [L, R) 和左闭右闭 [L, R] 只是循环条件不一样,收缩规则可以照抄,右边都写成 R = mid - 1。
正解开区间的 R 本身不参与比较,它只表示“排除区的起点”,收缩时直接 R = mid 即可。写成 R = mid - 1 会把一个尚未比较的候选元素漏掉,最终返回错误答案。想清楚“光圈哪端是亮的、哪端是暗的”,规则就不会抄错。
为何常见:初学者只背“等于就改 R”的模板,不理解开闭区间对端点语义的根本影响,换一种写法就翻车。
面试题
Q1. 在有序数组 [1, 2, 2, 2, 3, 4] 中查找 2 的左边界(第一个 2 的下标),结果是?
- A.0
- B.1← 正确答案
- C.2
- D.3
答案:B。左边界查找在 arr[mid] = 2 时不返回,而是继续收缩右端,最终 L 停在第一个 2 的位置。就像引座员看到 3 号还往左追,直到左边没有 3 号为止。手动推演一遍:L 最终落回下标 1。
Q2. 左边界查找中,当 arr[mid] >= target 时应该执行哪一步?
- A.直接返回 mid
- B.L = mid + 1
- C.R = mid - 1← 正确答案
- D.R = mid
答案:C。左边界的核心是“等值也向左收”:arr[mid] >= target 说明第一个 target 不可能在 mid 右边,所以把右端收到 mid 左侧,即 R = mid - 1。选 C。A 是标准二分的做法,会丢掉左边界信息。
Q3. 二分搜索(含边界变体)在 n 个元素的有序数组中查找的时间复杂度是?
- A.O(n)
- B.O(n log n)
- C.O(log n)← 正确答案
- D.O(1)
答案:C。每次比较排除一半区间,n 个元素最多需要 log₂n 次,所以是 O(log n)。这就像手电筒每照一次光圈就缩小一半,从 20 个座位减到 10 个、5 个、2 个,几次就找到了。
Q4. 在有序数组 [1, 2, 2, 2, 3, 4] 中查找 2 的右边界(最后一个 2 的下标),结果是?
- A.1
- B.2
- C.3← 正确答案
- D.4
答案:C。右边界查找在 arr[mid] = 2 时继续收缩左端(L = mid + 1),直到区间为空,返回 R = 3。三个连续的 2 占据下标 1、2、3,最后一个在 3。选 C。
Q5. 对于有序数组 [1, 3, 5, 7, 9],lower_bound(4) 的返回值是?
- A.1
- B.2← 正确答案
- C.3
- D.4
答案:B。lower_bound 返回第一个 >= 4 的下标。数组中第一个不小于 4 的元素是 5,它在下标 2,所以返回值是 2。这就是“插入位置”语义:把 4 插到下标 2,数组依然有序。选 B。
Q6. 当 target 不存在于数组中时,lower_bound 返回什么?
- A.-1
- B.第一个大于 target 的元素下标← 正确答案
- C.数组长度 len(arr)
- D.最后一个小于 target 的元素下标
答案:B。lower_bound 返回第一个 >= target 的位置,target 不存在时就是第一个大于它的位置,这同时是可插入位置。C 是 target 大于所有元素时的特例,不是一般答案。好比引座员没找到 3 号,但能告诉你新人该坐哪个空位。
Q7. 用左闭右开 [L, R) 写法查找左边界时,正确的收缩规则是?
- A.R = mid - 1
- B.R = mid← 正确答案
- C.L = mid
- D.R = mid + 1
答案:B。开区间的 R 是“排除区起点”,不参与比较,收缩时直接 R = mid 即可;写成 mid - 1 会漏掉候选元素。选 B。注意循环条件也要从 L <= R 改成 L < R。
Q8. 有序数组 [1, 3, 5, 7, 9],lower_bound(4) 的返回值是?
- A.1
- B.2← 正确答案
- C.3
- D.4
答案:B。lower_bound(4) 要找第一个 >= 4 的下标,arr = [1, 3, 5, 7, 9] 中 5 是第一个不小于 4 的元素,下标为 2,所以返回 2。这个结果同时表示“4 应插入的位置”,把 4 放到下标 2 后数组依然有序。选 B。
Q9. 以下关于右边界查找的说法,正确的有?
- A.采用 L = mid 收缩时,mid 应向上取整← 正确答案
- B.采用 L = mid + 1 收缩时,mid 可以向下取整← 正确答案
- C.查找结束时一定有 L == R,返回哪个都行
- D.等值时收缩左端 L = mid + 1← 正确答案
答案:ABD。A 对,L = mid 且 R = L + 1 时必须上取整否则死循环;B 对,mid + 1 保证每轮收缩,下取整安全;C 错,左闭右闭退出时 L = R + 1,两者从不相等;D 对,右边界等值时应向右追,收缩左端。
Q10. 以下哪些场景适合用二分边界变体解决?
- A.在有序数组中查找第一个 >= x 的元素← 正确答案
- B.计算有序数组中某个值出现的次数← 正确答案
- C.在无序数组中查找最大值
- D.统计有序数组中落在 [a, b] 区间内的元素个数← 正确答案
答案:ABD。A 就是 lower_bound 的定义;B 用 upper_bound - lower_bound + 1 一次算出;D 用 lower_bound(a) 和 upper_bound(b) 锁区间。C 错,无序数组无法二分,必须线性扫描。边界二分的前提永远是有序。