想象你是电影院的引座员,手里只有一支手电筒。今晚有一排 20 个座位,编号从 0 到 19,七个素不相识、却都买了 3 号票的观众坐在这排的连座上。你接到两个任务:先找到最左边那位 3 号观众,再找到最右边那位 3 号观众。挨个座位照过去当然可以,但电影马上开场,太慢了。聪明的做法是:先照正中间的座位,根据座位上的人告诉你“大了”还是“小了”,一次性排除一半座位。这就是二分搜索。可问题来了:当你照到一位 3 号观众时,你敢说这就是最左边的吗?不一定——左边可能还坐着三号。于是你需要两种“边界变体”:一种向左追到第一个三号,一种向右追到最后一个三号。这个看似微小的改动,背后藏着区间收缩、终止条件和死循环的整套学问。本文就用这支手电筒,带你把二分搜索的边界问题看得明明白白。

岗位关联

二分边界变体是算法面试与工程检索的高频考点,掌握它就能在索引、范围查询与排序数据统计中写出 O(log n) 的高效代码,是算法工程师的必备基本功。

目录
  1. 可视化演示
  2. 标准二分基础
  3. 左边界查找
  4. 右边界查找
  5. 区间写法对比
  6. 应用场景
  7. 死循环防范
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

二分搜索的边界变体包括查找左边界和右边界,用于在有序数组中找到目标值的首次和末次出现位置。本文讲解左边界查找的... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

一、标准二分:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

标准二分基础

引座员第一次拿起手电筒,最聪明的策略不是从 0 号座位挨个照,而是先照正中间。如果中间那位观众说自己是 2 号、比 3 号小,那么他左边所有的座位都不可能是 3 号了——因为这一排座位号从左到右递增。一次照射排除一半,这就是二分搜索的原型。它有一个大前提:座位号必须有序,否则“中间比目标小”根本推不出“左边全都小”。

在算法术语里,“这一排座位”叫有序数组,座位编号叫下标,要找的票号叫目标值 target。每次取当前区间 [L, R] 的中点 mid,比较 arr[mid] 与 target:中间值太小就舍弃左半边,太大就舍弃右半边。区间每轮缩小一半,n 个元素最多查 log₂n 次,这就是对数时间复杂度的由来。整个过程的循环不变量是:“只要 target 存在,它就一定还在当前 [L, R] 区间里”——每次收缩都必须守住这句话,手电筒才不会照丢人。

mid=L+⌊R−L2⌋\displaystyle \text{mid} = L + \left\lfloor \frac{R - L}{2} \right\rfloor

1. 计算区间跨度:用 R - L 求出当前待查区间的间隔数,比如 L=0、R=9 时跨度是 9。

R−LR - L

2. 跨度折半:把跨度除以 2,得到区间一半的长度,这是“折半”思想的数学表达。

R−L2\frac{R - L}{2}

3. 向下取整:用 floor 抹掉小数,保证 mid 是合法的整数下标。

⌊R−L2⌋\left\lfloor \frac{R - L}{2} \right\rfloor

4. 偏移回中点:把相对偏移加回左端点 L,变成数组里真实的中间位置下标。

mid=L+⌊R−L2⌋\text{mid} = L + \left\lfloor \frac{R - L}{2} \right\rfloor

手电筒一次照亮一半,这就是标准二分。但它只能回答“有没有”这个是非题,回答不了“第一个在哪儿、最后一个在哪儿”。于是引座员必须学会两种更精细的照法:一种向左追,一种向右追。

左边界查找

引座员的新任务是找到最左边的 3 号。他照到中间座位,发现是 3 号,但他不敢停——左边可能还坐着别的 3 号。于是他把照射范围的右端收回到 mid 的左侧(R = mid - 1),继续向左追。每一次照到 3 号都往左收一次,直到光圈彻底空掉。这个过程就是“左边界查找”,也叫 lower_bound。

规则只有一句话:只要 arr[mid] >= target,就把右边界收缩到 mid - 1;否则(arr[mid] < target)把左边界收缩到 mid + 1。注意“等于也要向左收”,这正是它与标准二分最大的区别——标准二分见到相等就 return,而左边界查找见到相等反而要逼自己往左看,因为左边可能存在更靠前的目标。收缩过程中,循环不变量依然是“第一个目标一定在当前区间内”,只是现在的判断标准从“有没有”变成了“第一个在哪”。

while L≤R:if arr[mid]≥t, R=mid−1; else L=mid+1; 返回 L\displaystyle \text{while } L \le R: \quad \text{if } arr[mid] \ge t,\ R = mid - 1;\ \text{else } L = mid + 1;\ \text{返回 } L

1. 循环条件:L <= R 表示手电筒光圈里还有候选座位,就继续照。

L≤RL \le R

2. 取中点:和标准二分一样求 mid,这是每次照射的落点。

mid=L+⌊R−L2⌋\text{mid} = L + \left\lfloor \frac{R - L}{2} \right\rfloor

3. 相等或偏大向左收:arr[mid] >= t,说明第一个 t 不可能在 mid 右边,把右端收到 mid 左侧。

R=mid−1R = mid - 1

4. 偏小向右收:arr[mid] < t,说明目标只可能在右边,把左端收到 mid 右侧。

L=mid+1L = mid + 1

5. 返回左指针:退出时 L 正是第一个 >= t 的位置,即左边界。

返回 L\text{返回 } L

演示图中上排完整走了一遍这个过程: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 就必须向上取整,否则区间只剩两个元素时会陷入死循环,我们留到“死循环防范”一节再拆解。

while L≤R:if arr[mid]≤t, L=mid+1; else R=mid−1; 返回 R\displaystyle \text{while } L \le R: \quad \text{if } arr[mid] \le t,\ L = mid + 1;\ \text{else } R = mid - 1;\ \text{返回 } R

1. 相等或偏小向右收:arr[mid] <= t,说明最后一个 t 不可能在 mid 左边,把左端收到 mid 右侧。

L=mid+1L = mid + 1

2. 偏大向左收:arr[mid] > t,说明目标在左边,把右端收到 mid 左侧。

R=mid−1R = mid - 1

3. 返回右指针:退出时 R 恰好是最后一个 <= t 的位置,即右边界。

返回 R\text{返回 } R

回到演示图的下排:照到 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,这个位置就是答案。一句话记忆:闭区间“比较过的就排除”,开区间“右端本来就是排除区,收进来不用减一”。

左闭右开: while L<R:if arr[mid]≥t, R=mid; else L=mid+1; 返回 L\displaystyle \text{左闭右开: } \text{while } L < R: \quad \text{if } arr[mid] \ge t,\ R = mid;\ \text{else } L = mid + 1;\ \text{返回 } L

1. 开区间语义:[L, R) 中 R 是“已排除区间的起始点”,它本身不参与候选,只负责划定边界。

[L, R)[L,\ R)

2. 退出条件不同:区间缩到只剩一个元素时 L == R,循环结束,答案就是 L。

L<RL < R

3. 右端收缩:因为 R 不参与比较,收缩时把 R 拉到 mid 即可,不需要 mid - 1。

R=midR = mid

4. 左端收缩:L 参与比较,收缩时必须排除 mid,所以是 mid + 1。

L=mid+1L = 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。数据库索引的范围扫描、特征工程里的区间统计、日志按时间戳截取,全都在用这套逻辑。

count=upper−lower+1,若 lower>upper,则 count=0\displaystyle \text{count} = \text{upper} - \text{lower} + 1, \qquad \text{若 } \text{lower} > \text{upper} \text{,则 } \text{count} = 0

1. 定位左界:lower = lower_bound(t) 返回第一个 >= t 的下标,作为区间起点。

lower=lower_bound(t)\text{lower} = \text{lower\_bound}(t)

2. 定位右界:upper = upper_bound(t) 返回最后一个 <= t 的下标,作为区间终点。

upper=upper_bound(t)\text{upper} = \text{upper\_bound}(t)

3. 计算长度:闭区间元素个数 = 终点 - 起点 + 1。

count=upper−lower+1\text{count} = \text{upper} - \text{lower} + 1

4. 空集判定:当 lower > upper 时表示区间为空,个数记 0。

count=0\text{count} = 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) 这类非法下标。最后别忘了用边界用例测试:空数组、全部相等、目标在两端、目标不存在,四个用例各跑一遍,代码才敢上线。

mid=L+⌈R−L2⌉=L+⌊R−L+12⌋\displaystyle \text{mid} = L + \left\lceil \frac{R - L}{2} \right\rceil = L + \left\lfloor \frac{R - L + 1}{2} \right\rfloor

1. 陷阱现场:当区间只剩两个元素(R = L + 1)且收缩规则是 L = mid 时,向下取整会让 mid = L,左端永远不动。

R−L=1R - L = 1

2. 向上取整:把折半方式改成 ceil,mid 会取到 R,区间必然向左收缩。

mid=L+⌈R−L2⌉\text{mid} = L + \left\lceil \frac{R - L}{2} \right\rceil

3. 等价整数写法:ceil((R-L)/2) 等于 floor((R-L+1)/2),便于用整除实现。

⌈R−L2⌉=⌊R−L+12⌋\left\lceil \frac{R - L}{2} \right\rceil = \left\lfloor \frac{R - L + 1}{2} \right\rfloor

引座员终于顿悟:二分查找的命门不是“聪明”,而是“每轮都必须缩小范围”。只要守住这条铁律,手电筒的每一次闪烁都离真相更近一步;守不住,再亮的灯也只能在原地打转。

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 演示

python49 行
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()
目的:完整实现左边界查找、右边界查找与区间计数,并用多组边界用例验证正确性。
  1. if arr[mid] >= target:左边界的关键判断:等值也不停,继续向左收。
  2. right = mid - 1把右端收到 mid 左侧,排除已比较过的元素。
  3. if arr[mid] <= target:右边界的关键判断:等值继续向右追。
  4. left = mid + 1把左端推到 mid 右侧,保证区间每轮收缩。
  5. mid = left + (right - left) // 2防溢出的中点写法,等价于 (left+right)//2 但更安全。
  6. if lb <= ub and lb < len(arr) and arr[lb] == t:用边界关系判断 target 是否存在,比“返回 -1”语义更丰富。
  7. 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 的下标),结果是?

单选题 · easy

  • A.0
  • B.1← 正确答案
  • C.2
  • D.3

答案:B。左边界查找在 arr[mid] = 2 时不返回,而是继续收缩右端,最终 L 停在第一个 2 的位置。就像引座员看到 3 号还往左追,直到左边没有 3 号为止。手动推演一遍:L 最终落回下标 1。

Q2. 左边界查找中,当 arr[mid] >= target 时应该执行哪一步?

单选题 · easy

  • 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 个元素的有序数组中查找的时间复杂度是?

单选题 · mid

  • 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 的下标),结果是?

单选题 · easy

  • 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) 的返回值是?

单选题 · mid

  • A.1
  • B.2← 正确答案
  • C.3
  • D.4

答案:B。lower_bound 返回第一个 >= 4 的下标。数组中第一个不小于 4 的元素是 5,它在下标 2,所以返回值是 2。这就是“插入位置”语义:把 4 插到下标 2,数组依然有序。选 B。

Q6. 当 target 不存在于数组中时,lower_bound 返回什么?

单选题 · mid

  • A.-1
  • B.第一个大于 target 的元素下标← 正确答案
  • C.数组长度 len(arr)
  • D.最后一个小于 target 的元素下标

答案:B。lower_bound 返回第一个 >= target 的位置,target 不存在时就是第一个大于它的位置,这同时是可插入位置。C 是 target 大于所有元素时的特例,不是一般答案。好比引座员没找到 3 号,但能告诉你新人该坐哪个空位。

Q7. 用左闭右开 [L, R) 写法查找左边界时,正确的收缩规则是?

单选题 · mid

  • 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) 的返回值是?

单选题 · extreme

  • 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. 以下关于右边界查找的说法,正确的有?

多选题 · hard

  • 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. 以下哪些场景适合用二分边界变体解决?

多选题 · mid

  • 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 错,无序数组无法二分,必须线性扫描。边界二分的前提永远是有序。