news 2026/10/11 7:13:12

二分查找的本质与边界技巧:从有序数组到二分答案与浮点逼近

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
二分查找的本质与边界技巧:从有序数组到二分答案与浮点逼近

聊到二分查找,很多人的第一反应是“不就是在一个有序数组里找一个数嘛,写个 while (l <= r) 就行”。但我在带新人、改算法的过程中发现,真正能把二分查找的应用玩明白的人真不多。它远远不止“查找”这么简单,更是一套“不断排除一半答案”的思维框架,能解决边界定位、最优值猜测、浮点逼近、旋转数组、峰值查找等各种看似不相关的问题。PTA上的“二分查找函数题”每年都能挂掉一批人,尤其是重复元素要区分“第一个”和“最后一个”的时候;算法题里二分的变体更是常客。这篇文章我就从实际做题和改题的角度,把二分查找的实现细节、边界变体、二分答案、浮点二分以及常见坑完整讲一遍,适合正在刷PTA、准备期末考试或算法面试的读者。

1. 二分查找的本质与应用版图

1.1 从“有序数组找数”到“单调性判定”

最基础的二分查找大家都清楚:数组有序,每次取中点,和目标值比大小,然后扔掉一半。可你有没有认真想过,为什么数组有序就能二分?本质上是因为数组元素相对 target 具有单调性——在某个分界点之前都小于 target,分界点之后都大于等于 target。每次比较本质是一次“判定”,这次判定的结果能明确排除掉一半候选位置。

这个思想一旦抽象出来,二分查找的应用范围就远不止“有序数组”这一个场景。比如你想在一个很大的值域 [L, R] 中找一个“最小的可行解”,只要你能快速写一个 check(mid) 判断“mid 是否可行”,并且可行性与 mid 的关系是单调的,那就能对值域二分。再比如浮点数方程求根、旋转数组中的查找、无序数组中找峰值,背后都是同一套“通过比较,排除掉不可能的一半”的逻辑。所以我经常跟人说,二分的本质是“单调性”,而不是“数组有序”。

1.2 二分查找的主要应用场景分类

把二分查找的应用场景梳理一下,大致有下面几类:

  • 精确查找:在有序数组里找给定值,返回任意一个下标。
  • 边界查找:找第一个等于 target 的位置、最后一个等于 target 的位置、第一个大于等于 target 的位置等。
  • 二分答案:答案落在一个整数或实数区间内,直接求解困难,但给定一个候选值能快速判断可行性。典型如“最大值最小化”“最小值最大化”。
  • 浮点二分:用浮点数逼近方程根或极值,常见于几何计算、数值计算。
  • 特殊数组二分:旋转有序数组、峰值查找、二维递增矩阵查找等。
  • 结构性二分:在树状数组上二分找第 k 个前缀和、在有序集合中按位置拆分等。

这篇文章会重点讲最容易踩坑的边界二分和二分答案,再带一下旋转数组和浮点二分,最后用 PTA 函数题串一遍实战流程。对于刚从基础入门的读者,先把前两类吃透,后面的内容会帮你打开新世界。

2. 边界查找:PTA函数题里最容易被扣分的细节

2.1 为什么需要区分“第一个”和“最后一个”

拿最常见的需求来说:统计一个有序数组里某个数出现的次数。很多人第一反应是二分找出任意一个 target,然后往左右两边线性扫描。这个思路不能说错,但如果数组里恰好全是同一个数,线性扫描会直接退化到 O(n),二分就白写了。正确做法是分别二分出“第一个等于 target 的下标”和“最后一个等于 target 的下标”,两个下标相减再加 1 就是出现次数。

这也是 PTA 函数题里特别爱考的变化。有些同学第一次写“找第一个等于 target”的二分时,总是喜欢在中点找到 target 后立刻 return。数组没有重复元素时没问题,但一旦有重复元素,这个 return 返回的可能是最后一个,也可能不是第一个。题目要求“返回最小下标”,你就 WA 了。这时候需要的不是“找到任意一个”,而是不断压缩右边界,直到确定最左边的命中位置。

2.2 标准实现与循环不变式

先看“找第一个等于 target”的写法。我这里统一用左闭右闭区间 [l, r],因为它跟数组下标的直觉最贴近。

int binarySearchFirst(int a[], int n, int target) { int l = 0, r = n - 1; int ans = -1; while (l <= r) { int mid = l + (r - l) / 2; if (a[mid] >= target) { if (a[mid] == target) { ans = mid; } r = mid - 1; } else { l = mid + 1; } } return ans; }

核心思路是:当 a[mid] >= target 时,第一个等于 target 的位置要么是 mid,要么在 mid 左边,所以先记录 ans = mid(当然要满足 a[mid] == target),然后 r = mid - 1 继续往左找;当 a[mid] < target 时,target 肯定在右边,所以 l = mid + 1。循环结束时,ans 保存的就是最左边的命中下标;如果整个数组里没有 target,ans 一直是 -1。

再看“找最后一个等于 target”,逻辑刚好反过来,判断条件用 <=,没命中时往右收缩。

int binarySearchLast(int a[], int n, int target) { int l = 0, r = n - 1; int ans = -1; while (l <= r) { int mid = l + (r - l) / 2; if (a[mid] <= target) { if (a[mid] == target) { ans = mid; } l = mid + 1; } else { r = mid - 1; } } return ans; }

很多初学者会把“找最后一个”写成 if (a[mid] < target) 再单独处理相等,这样不是不行,但分支一多容易乱。上面这种把等于情况合并进 <= 分支,然后通过 ans 记录候选位置,思路很干净。这里的循环不变式是:每一轮循环开始时,你都相信“如果 target 存在,那么第一个等于它的位置一定还在 [l, r] 中,并且 ans 记录的是当前已经找到的最左候选”。只要这个不变式不被破坏,循环结束答案就是对的了。

还有一个细节要强调:mid 计算为什么用 l + (r - l) / 2 而不是 (l + r) / 2?因为当 l 和 r 都接近 INT_MAX 时,l + r 可能溢出。虽然普通的 OJ 题不一定能碰到,但养成这个习惯,换到更极端的场景不会翻车。

2.3 PTA函数题的实用写法

PTA 上有一道很常见的函数题,原型类似这样:在长度为 n 的升序数组 a 中查找 key,找到返回下标,找不到返回 -1。如果题目没有额外要求“返回第一个等于 key 的下标”,那么用最普通的精确查找模板就够了。

int binary_search(int a[], int n, int key) { int l = 0, r = n - 1; while (l <= r) { int mid = l + (r - l) / 2; if (a[mid] == key) { return mid; } else if (a[mid] < key) { l = mid + 1; } else { r = mid - 1; } } return -1; }

需要特别提醒:PTA 函数题只要求提交函数实现,不要自己写 main,也不要在函数里打印调试信息。我见过不止一个同学把 printf 调试语句留在提交代码里,OJ 输出多了自然判错。另外,PTA 有时会把数组下标定义成 1-based,比如题目说“下标 1 到 n 存放数据,0 号位不用”,那 l 初始化就应该是 1,r 初始化是 n,返回的下标也是 1-based。审题时先确认这件事,不然同样的模板要么越界,要么答案整体偏移。

3. 二分答案:把“求最优解”变成“判定可行性”

3.1 什么时候该用二分答案

判断标准其实很直接:题目让你求一个最值,而你发现“给定一个猜测值 x,能在 O(n) 或更短时间判断这个 x 是否可以达到”,那就可以二分答案。直接算很难,但判可行性容易,这种题天然适合二分。

比如要把 n 根木材切成统一长度,问最长能切到多少。这个问题直接列方程几乎没法解,但如果你告诉我“每段长度 x”,我可以很快算一遍每根木材能切出几段,累加起来看是否达到目标。这就是典型的“答案可二分”。单调性也很直观:x 增大时,能切出的总段数只可能减少或不变;x 减小时,总段数只可能增多或不变。如果 check(x) 不满足单调性,二分就不是“排除一半”,而是赌博,所以看到没有单调性的题目,不要硬套二分答案。

3.2 整数二分的模板与方向控制

整数二分答案最容易出错的是方向:求的是“最小可行解”还是“最大可行解”?模板其实就差两行。以“最小可行解”为例,mid 可行时记录 ans,并把右边界压到 mid - 1,因为更小的值可能也可行;mid 不可行时把左边界推到 mid + 1。

bool check(int x) { // 返回 x 是否可行 return true; } int main() { int l = 1, r = 1e9, ans = -1; while (l <= r) { int mid = l + (r - l) / 2; if (check(mid)) { ans = mid; r = mid - 1; // 继续找更小的可行解 } else { l = mid + 1; } } printf("%d\n", ans); return 0; }

如果要求“最大可行解”,就把 check 成功后的处理改成 ans = mid; l = mid + 1。很多人死记一套模板,结果做了两道题全是 WA。我建议每次写二分答案之前,先问自己:check(mid) 为真时,答案还能更小还是更大?想清楚再动笔,比背模板靠谱得多。

还有一个隐藏坑:check 函数里如果 mid 取 0,可能会出现除以 0、数组越界等问题。对于答案最小是 1 的题,l 直接设成 1,再单独处理“找不到可行解输出 0”的情况。

3.3 真题实战:木材切成最大等长段

拿经典的木材加工题当例子,PTA 和洛谷都有类似题目。有 n 根原木,长度分别为 L[i],现在要把它们切成 k 段长度相同的小段,问单段最大长度是多少。如果单段长度为 x,那么一根原木可以贡献 L[i] / x(取整)段,累加就是总段数。check(x) 返回总段数是否大于等于 k。

#include <cstdio> int n, k; long long L[100005]; bool check(int x) { long long cnt = 0; for (int i = 0; i < n; i++) { cnt += L[i] / x; } return cnt >= k; } int main() { scanf("%d%d", &n, &k); long long maxL = 0; for (int i = 0; i < n; i++) { scanf("%lld", &L[i]); if (L[i] > maxL) maxL = L[i]; } int l = 1, r = maxL, ans = 0; while (l <= r) { int mid = l + (r - l) / 2; if (check(mid)) { ans = mid; l = mid + 1; } else { r = mid - 1; } } printf("%d\n", ans); return 0; }

这里要求的是“最大可行解”,所以 check(mid) 为真时记录 ans,再往更大的值试探。r 的上界取 maxL,因为单段长度不可能超过最长原木。时间复杂度 O(n log maxL),n 是 1e5、L 是 1e9 时完全够快。注意 ans 初值是 0,表示一段都切不出来时输出 0。如果 l 从 0 开始,check 里会除以 0,所以这里 l 从 1 起步,用 ans=0 兜底。

吃透这一道题,就能迁移到分巧克力、装船问题、机器人搬砖等一大类二分答案题。它们共同点都是:不直接求最优值,而是判断某个猜测值是否可行,然后反复二分直到逼近答案。

4. 浮点二分与特殊结构上的二分

4.1 浮点二分的精度与终止条件

整数二分需要纠结 l <= r 还是 l < r,浮点二分反而更简单:循环条件直接写成 while (r - l > eps),eps 根据题目精度要求确定,一般取 1e-6 或 1e-7。由于浮点精度有限,我们不需要也不能通过 l == r 退出循环。

double binarySqrt(double x) { if (x < 1) return x; double l = 0, r = x; double mid; while (r - l > 1e-7) { mid = l + (r - l) / 2; if (mid * mid < x) l = mid; else r = mid; } return (l + r) / 2; }

注意浮点比较不要用 mid*mid == x 这种判断,因为浮点运算有误差。无论 l 还是 r,在终止时都已经非常接近答案,但题目如果要求保留三位小数,eps 设到 1e-4 或更小就够,不要设得比输出精度还要宽松很多。浮点二分在几何题里很常见,例如在时间和速度之间二分,或者找抛物线的交点。套路都是:把 mid 代入几何公式计算,根据结果判断当前 mid 偏大还是偏小。

4.2 旋转有序数组:先判断哪半段有序

一个有序数组经过旋转后,比如 [4,5,6,7,1,2,3],在这种数组里找 target。暴力扫描是 O(n),但只要用一点结构信息,就能在 O(log n) 内完成。每次拿到 mid,先判断左半段 [l, mid] 是否有序,再根据 target 是否落在这个有序区间里决定去哪边找。

int searchRotate(int a[], int n, int target) { int l = 0, r = n - 1; while (l <= r) { int mid = l + (r - l) / 2; if (a[mid] == target) return mid; if (a[l] <= a[mid]) { // 左半段有序 if (a[l] <= target && target < a[mid]) { r = mid - 1; } else { l = mid + 1; } } else { // 右半段有序 if (a[mid] < target && target <= a[r]) { l = mid + 1; } else { r = mid - 1; } } } return -1; }

判断左半段有序用的是 a[l] <= a[mid]。如果数组允许重复元素,这个条件可能不再可靠,最坏会退化成 O(n)。面试时如果被问到“有重复元素的旋转数组查找”,可以主动说一句“有重复时最坏线性,但平均仍然是对数级”。这个例子告诉我们,二分不一定要求整个数组严格有序,只要每次能从局部结构里得到“答案不可能在某一半”的信息就行。

4.3 寻找峰值:无序数组也能二分

再来一个更有冲击力的例子:在无序数组里找任意一个峰值,也就是 nums[i] 大于左右邻居,边界看作负无穷。数组本身无序,但相邻元素不相等。怎么二分?

关键在局部单调性:如果 nums[mid] > nums[mid+1],说明 mid 左侧一定存在峰值;反之说明右侧一定存在峰值,因为至少 mid+1 有可能是峰值。于是每次都能排除一半。

int findPeak(int a[], int n) { int l = 0, r = n - 1; while (l < r) { int mid = l + (r - l) / 2; if (a[mid] > a[mid + 1]) { r = mid; } else { l = mid + 1; } } return l; }

注意这里循环条件是 l < r 而不是 l <= r,并且更新时用的是 l = mid + 1、r = mid。为什么不是 r = mid - 1?因为当 a[mid] > a[mid+1] 时,mid 本身可能就是峰值,不能把它排除掉。这个模板重要的是保证区间始终保留“潜在峰值”,当 l == r 时,区间里只剩一个位置,它一定就是峰值下标。很多人在这里写成 r = mid - 1,结果漏掉峰值,查半天不知道错在哪。

从旋转数组和峰值这两个例子可以看出,二分真正依赖的只有一条:每次比较能确定答案不在某半边。它不需要数组整体有序,只要有一个方向性的判断就够了。

5. PTA题目实战与常见错误实录

5.1 读题时先确认五件事

PTA 的二分题风格比较多,尤其函数题,题面不长,但坑全藏在细节里。写代码前先花 30 秒确认下面五件事,能避开一半的 WA。

  • 数组下标起点是 0 还是 1?函数接口给的参数到底是长度还是最大下标?
  • 数组是升序还是降序?题目说“非递减”时,意味着可能有重复元素。
  • 有重复元素时,题目要求返回任意一个位置,还是第一个/最后一个位置?
  • 找不到时函数应该返回 -1,还是返回一个可能让人莫名其妙的值?
  • 提交的是完整程序还是只提交函数?后者不能有 main 和任何多余输出。

把这些确认好,再套模板。很多人题没读清就写二分,样例过了,隐藏测试点全挂。我见过最典型的错误是把“返回第一个大于 key 的位置”理解成“返回 key 所在位置”,样例里 key 恰好唯一存在,于是侥幸通过,换个数据就崩。

5.2 一个典型函数题的完整答案

假设 PTA 题目要求你实现 int binarySearch(int a[], int n, int key),在非递减数组 a 中查找 key,找到返回最小下标,找不到返回 -1。那么直接这样写:

int binarySearch(int a[], int n, int key) { int l = 0, r = n - 1, ans = -1; while (l <= r) { int mid = l + (r - l) / 2; if (a[mid] >= key) { if (a[mid] == key) ans = mid; r = mid - 1; } else { l = mid + 1; } } return ans; }

判断条件是 a[mid] >= key,而不是 a[mid] == key 后 return,因为要的是最小下标。如果题目改成“返回最大下标”,只需要把 >= 换成 <=,把 r = mid - 1 换成 l = mid + 1,其余不动。如果题目只要求任意一个下标,普通模板里找到就 return 更省事,不要盲目用边界模板。

还要提一点,PTA 的 C 语言函数题有时会用 C++ 编译器编译。如果你在函数里用了 C++ 特有语法可能编译失败。稳妥做法是函数内只用 C 风格数组访问,不依赖 vector、algorithm 等。如果非要用 C++ 的 lower_bound,记得 #include ,并且注意返回的是迭代器,数组名退化为指针后要 handle 一下,反而绕远。

5.3 死循环、溢出、返回错值排查

下面这几条是我帮别人 debug 时遇到的高频问题,几乎涵盖了 PTA 二分题的主要错误类型。

典型症状常见原因修复方式
程序卡死不输出mid 更新导致区间不缩小检查是否出现 l=mid 或 r=mid 且 mid 不变;统一用 l+(r-l)/2
返回下标差 1下标起点理解错误确认 0-based/1-based,以及 r 初始值是 n-1 还是 n
答案错误但样例全过没有处理重复元素的“第一个/最后一个”用 ans 记录候选值,别着急 return
运行错误/内存越界mid 用 (l+r)/2 导致整型溢出改用 l+(r-l)/2,必要时用 long long 参与计算
输出多余内容函数题里写了 printf 调试提交时只留函数体,删掉调试输出

死循环问题是重灾区。如果使用左闭右闭 [l,r],循环条件是 l <= r,每次进入循环后 mid 至少会让 l 或 r 发生移动,理论上不会死循环。但如果你用 l < r,又把 mid = (l+r)/2,更新时写成 l = mid 或 r = mid,那么当区间缩到两个相邻元素时,mid 可能永远等于 l,区间就不再缩小。比如 l=2, r=3 时 mid=2,如果更新到 l=mid,区间就永远停在 [2,3]。解决办法是:左闭右闭时用 l=mid+1 或 r=mid-1;左闭右开时用 l=mid+1 和 r=mid。绝对不能 l 和 r 都保持不变。

6. 我把二分写对的私有方法论

6.1 三个问题定下循环不变式

我自己现在写二分,动手前固定问三个问题。第一,答案可能存在的区间是什么?第二,这个区间用左闭右闭还是左闭右开?第三,当中间值不满足条件时,下一次搜索区间应该排除哪一半?把这三个问题答完,代码基本就出来了。

举个例子,找“第一个大于等于 target 的位置”。答案区间是 [0, n],其中 n 是数组长度,因为可能不存在这样的元素。我选左闭右开 [l, r),l 初始化为 0,r 初始化为 n。循环 while (l < r),mid = l + (r-l)/2。如果 a[mid] >= target,说明 mid 可能就是要找的第一个位置,而且答案不会在 mid 右边,于是 r = mid;否则 a[mid] < target,mid 不可能是答案,l = mid + 1。循环结束时 l == r,返回 l 就对了。

int lowerBound(int a[], int n, int target) { int l = 0, r = n; while (l < r) { int mid = l + (r - l) / 2; if (a[mid] >= target) { r = mid; } else { l = mid + 1; } } return l; }

这就是 C++ 标准库 lower_bound 的经典实现。我建议你也把左闭右开这套掌握,因为很多高级二分和树状数组二分都默认用左闭右开;如果只会一套,看别人的代码会吃力。不过要提醒的是,不要在同一段代码里混用两种区间表示。比如 while (l<r) 的循环里写 r=mid-1,常常会把正确答案排除掉。

6.2 对拍测试:让暴力验证二分

二分太容易错,那就不要只靠眼睛看。写一个暴力函数,再写一个二分函数,用随机数据对拍。比如你想验证“找第一个等于”的二分,可以写一个小脚本,随机生成排序数组和 target,然后对比二分结果和线性扫描结果。一万组全部一致,基本就放心了。

import random def binary_first(a, x): l, r, ans = 0, len(a) - 1, -1 while l <= r: mid = (l + r) // 2 if a[mid] >= x: if a[mid] == x: ans = mid r = mid - 1 else: l = mid + 1 return ans def force_first(a, x): for i in range(len(a)): if a[i] == x: return i return -1 for _ in range(10000): n = random.randint(1, 20) a = sorted(random.choices(range(1, 10), k=n)) x = random.randint(1, 10) if binary_first(a, x) != force_first(a, x): print("WA", a, x, binary_first(a, x), force_first(a, x)) break else: print("OK")

这种对拍方式在本地跑一跑,比空想边界要高效得多。实际比赛或作业里遇到任何边界模板,我都建议先用暴力随机测试验证一遍再提交。

6.3 几个避免翻车的小习惯

最后分享一些我这些年总结下来的习惯。第一,凡是二分数组,先确认数组长度,r 是 n-1 还是 n,这决定后面所有边界。第二,凡是需要返回 -1 的情况,优先用 ans 初值 -1,不要依赖循环结束后对 l+r 复杂判断。第三,把 check 函数单独抽出来写,不要和二分主逻辑混在同一行,出问题方便调试。第四,提交前删掉所有调试输出。第五,题目里出现“非递减”而不是“递增”时,默认可能有重复元素,该用边界二分就用边界二分,别心存侥幸。

写了这么多,我个人其实还是在“左闭右闭 + ans 记录”这个风格上最顺手,因为它跟普通查找模板最接近,不容易出错。但你完全可以选左闭右开,只要保持前后一致就没问题。二分查找的代码可以很短,但短代码越容易在边界上翻车。有了不变式意识,加上对拍验证,再复杂的变体也能稳稳拿下来。希望这篇实战总结能少让你受一点 WA 的折磨。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 7:13:03

云端与本地并行交付,软件许可管理如何实现统一运营?

摘要&#xff1a;同一款软件同时做云端、本地、内网、离线交付&#xff0c;授权规则最容易割裂成几套台账。这篇不讲概念&#xff0c;给一套可直接执行的验证方法&#xff1a;先解耦平台部署、运行环境、许可载体三个维度&#xff0c;再用"5 类环境 8 类生命周期事件&quo…

作者头像 李华
网站建设 2026/10/11 7:12:53

市场前景明朗:全球半导体键合设备预计2032年销售额突破64.94亿美元

2026年国内先进封装产能扩张进入深水区&#xff0c;大量晶圆制造与封测企业普遍面临半导体键合设备进口依赖度高、细间距异质集成工艺适配难、量产良率爬坡周期长的痛点&#xff0c;晶圆级混合键合、热压键合设备、Chiplet异质集成正成为破解行业痛点的核心方向。作为半导体先进…

作者头像 李华
网站建设 2026/10/11 7:10:30

构建AI助手技能系统:从架构设计到技能包开发实战

最近一直在折腾一件事&#xff1a;把常用的那个AI助手从“能聊几句”变成“真能干活”。核心就落在标题里那个词——skills。我给这套助手框架加了一层技能系统&#xff0c;让它不再只会生成文本&#xff0c;而是能去读文件、查数据、跑脚本&#xff0c;甚至定时执行任务。这篇…

作者头像 李华
网站建设 2026/10/11 7:07:42

优秀产品经理与糟糕产品经理:产品 CEO 的自我修养

一、引言&#xff1a;产品经理就是产品的 CEO优秀的产品经理对市场、产品、产品线以及竞争对手都有深入理解&#xff0c;并把这些理解建立在实际知识和稳定判断之上。可以说&#xff0c;一个优秀的产品经理就是产品的首席执行官&#xff1a;他承担全部责任&#xff0c;以产品的…

作者头像 李华
网站建设 2026/10/11 7:04:53

流失率降低30%的秘密:精细化客户与中心标签管理指南

你手机里躺着几千个客户微信&#xff0c;但打开通讯录翻一圈&#xff0c;能叫出名字、知道他上次买了什么、大概什么时候会再来的&#xff0c;有几个&#xff1f;很多人以为客户流失是因为产品不够好、价格不够低、竞品在挖人。但如果你去看那些真正把流失率压下来的团队&#…

作者头像 李华