尧图精选

为什么二分搜索要求数组有序?从机制到工程实践彻底讲透

🕒 发布时间:2026/10/2 9:52:29 📁 来源:尧图网络
很多人对二分搜索的理解停留在“背模板”的层面while left right、mid left (right - left) // 2、然后根据比较结果收缩区间。但一旦被问到“为什么输入数组必须是有序的”大多数人的回答就变成了“因为二分搜索就是要求有序的啊”或者说“因为要比较大小才能决定往哪边走”。这两种回答其实都没有说到根子上。比较大小只是手段真正的关键在于只有数组有序我们在中点位置做一次比较才能把“目标值只可能存在于左半段或右半段”这条信息变成确定性的结论。换句话说有序性保证让一次比较获得了“排除半区”的效力一旦这个保证不存在二分搜索的效率就不是从 O(log n) 退化成 O(n) 这么简单而是整个算法赖以成立的基础从根基上被抽掉了。这篇文章我会从机制、失效场景、数学本质、工程坑位和扩展应用五个层面把这个问题彻底讲透。适合正在准备算法面试的人、给学生讲二分搜索的同行以及所有想建立算法直觉、不想停留在“会默写模板”阶段的开发者。1. 表面原因是需要比较中点根本原因是比较结果必须能“外推”到整个半区先把最基础的东西对齐。一个标准的二分搜索查找目标值长这样def binary_search(arr, target): left, right 0, len(arr) - 1 while left right: mid left (right - left) // 2 if arr[mid] target: return mid elif arr[mid] target: left mid 1 else: right mid - 1 return -1我们用一组具体的数字演示区间如何收缩。假设数组是[2, 4, 6, 8, 10, 12, 14]目标值是7轮次leftrightmidarr[mid]比较结果收缩动作106388 7right 2排除右侧四个元素202144 7left 2排除左侧两个元素322266 7left 3区间为空返回 -1注意看第一轮arr[3] 8它比目标值7大我们立刻把right改成2也就是把索引 3 到 6 这半边全部放弃了。为什么敢放弃因为数组是递增的arr[3]已经是 8 了那arr[4]、arr[5]、arr[6]只可能比 8 更大更不可能等于 7。这不是在对某一个元素下判断而是在对一整段区间下判断。这就是有序性的核心贡献它让“中点元素与目标值的大小关系”和“整个左右半区与目标值的大小关系”之间建立了等价性。当arr[mid] target时由于数组单调递增所有索引小于等于 mid 的元素都不大于arr[mid]因此它们全部小于 target整段左半区可以直接从候选区间里划掉。当arr[mid] target时同理从 mid 到 right 的所有元素都不小于arr[mid]因此全部大于 target整段右半区直接划掉。所以所谓的“有序性保证”本质上是在保证一件事中点的值可以代表它所在那一侧的极值。在递增数组里arr[mid]就是左半区的最大值同时也是右半区的最小值严格递增时。一个点的取值映射出了它左右两侧所有元素取值范围的边界。这才是“比较中点即可确定目标值只可能存在于左半段或右半段”这句话的真正含义。重要二分搜索的正确性不依赖“每次都能找到 target”而是依赖“每次排除掉的区间里一定不包含 target”。这个更严格的要求恰好就是有序性所提供的。很多人写二分容易忽略循环不变量loop invariant这个东西。上面那段代码其实维护了这样一个不变式如果 target 存在于数组中那么它一定落在[left, right]这个闭区间内。每一轮循环做的事情是在保证这个不变式不被破坏的前提下把一个中点位置排除掉或者把包含中点的一整侧排除掉从而让区间的长度严格减小。区间的长度不断减半循环才能在对数轮之内结束。一旦你写的分支逻辑无法维持这个不变式就可能出现区间不缩小甚至无限循环的问题这个我在第 4 部分会展开讲。2. 有序性到底在保护什么从一个无序数组上看到规则如何失效现在把数组打乱换成[6, 2, 14, 8, 4, 12, 10]还是找目标值7。数组长度不变初始left 0right 6中点索引是 3arr[3] 88 比 7 大。按照有序数组的逻辑我们是不是应该把右半区全部舍弃看看右半区有什么索引 4 是 4索引 5 是 12索引 6 是 10。4 比 7 小12 和 10 比 7 大——目标值7完全可能藏在右半区里。再看左半区索引 0 是 6索引 1 是 2索引 2 是 14同样是一边大一边小。左右两边都有可能出现 7一次比较之后候选区域从 7 个元素缩到了 6 个元素只少了一个arr[3]。这就是无序数组里比较中点的真实信息量它只能告诉你“这个点不是目标值”除此之外什么实质性的结论都推导不出来。因为你不知道数组的排列规律所以无法从arr[mid] target推知任何一侧元素的分布情况。继续在这个无序数组里模拟二分会发生什么假设我们强行用二分的框架arr[3] 8 7然后right 2直接在 6、2、14 这三个数里找 7找不到返回 -1。但数组里其实藏着 7 吗没有所以这个例子看不出问题。换一个数组[6, 2, 14, 7, 4, 12, 10]目标 7中点索引 3 恰好是 7能命中是运气。但如果目标是 4中点不是 4我们根据比较结果无论往哪边收缩都可能把真正存有 4 的那一侧给丢掉目标 4arr[3] 7 4按规则走左半区[0, 2]但 4 在索引 4被我们排除掉了永远找不到。目标 12arr[3] 7 12按规则走右半区[4, 6]如果 12 在索引 0同样被丢掉。所以在无序数组上二分搜索不仅可能低效更致命的是可能直接给出错误答案。它会把“这个点比较大所以右边都比它大”这个只属于有序数组的推论错误地套用到任意数组上。最坏情况下你要找到无序数组中的一个元素只能退化为线性扫描逐个比较 n 个元素。这不是因为我们没有更聪明的算法而是数学上能够证明在没有任何额外结构信息的条件下对任意排列的数组查找一个目标值最坏情况下必须检查 n 个位置。中间那个“必须”来自对手论证——你每查看一个位置对手都可以把目标值安排在还没被查看过的某个位置直到你检查完最后一个位置才能确定。没有有序性就没有“分治”可言。注意有时你会看到“乱序数组也能二分”的说法其实指的是随机化快速选择quickselect里的分治逻辑它找的是第 k 小元素而不是精确的目标值而且它利用的是“随机 pivot 的期望排除比例”而非确定性的半区排除正确性不依赖数组有序但复杂度分析是期望意义上的跟经典的二分查找完全是两回事不要混淆。有序性真正保护的是“可排除性”。在有序数组里每轮循环总能找到一条清晰的分界线把候选区间对半切开而且切掉的半区证明确实不含目标。这是二分查找能够以 O(log n) 时间完成搜索的底线保障。3. 从信息论视角看有序性如何把二分搜索推到理论下限这里我再用信息量来解释一下为什么二分搜索的效率恰好是最优的以及有序性在其中扮演什么角色。假设有一个长度为 n 的有序数组目标值可能藏在 n 个位置中的任意一个也可能不存在但为了简化先假设一定存在。你要通过比较元素值来定位它。每一次比较本质上是在向数组提出一个“是/否”的问题最多得到 1 bit 的信息量严格说三路比较可以获得更多一点信息但决策树的二进制分支模型里一次三路比较也能拆成常数次二路比较不影响数量级。要在 n 种可能性里确定唯一的一个答案至少需要 log2(n) bit 的信息。因此任何基于比较和决策的搜索算法最坏情况下至少要执行 log2(n) 次核心比较。这是信息论给出的下界与具体算法无关。二分搜索厉害在哪里它让每轮比较都赚足了 1 bit 的信息。这需要比较的结果具备某种“全局代表性”——中点和目标的大小关系必须能同时回答 n/2 个候选位置各自的“是不是目标”问题。有序数组恰好提供了这种代表因为数组单调这 n/2 个位置上的值整体偏小或整体偏大它们的归属一下子就被确定不需要逐个去问。换一种说法有序数组在“数组索引”和“元素值域”之间建立了单调映射。数组下标从 0 到 n-1 是天然有序的元素值从小到大也是天然有序的两个有序集合之间用单调函数对齐于是“比较索引”和“比较值”就等价了。二分搜索本质上是在值的空间里做折半但因为值和索引一一对齐折半就变成了在索引空间里做折半。无序数组把这种对齐关系彻底打乱索引顺序和值的大小完全脱钩你比较一个索引的位置无法给其他位置提供任何信息每轮只能排除一个候选点信息获取效率断崖式下跌。作为对比我们看一个“部分有序”的例子旋转数组比如[7, 8, 9, 1, 2, 3, 4]。整个数组不是单调递增的它由两段各自递增的序列拼接而成。这里能不能二分能。搜索思路是先在每次循环里判断mid落在左段还是右段确定哪一段是有序的然后利用这一段内部的单调性来判断 target 是否落在其中def search_rotated(nums, target): left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: return mid # 左半段有序 if nums[left] nums[mid]: if nums[left] target nums[mid]: right mid - 1 else: left mid 1 # 右半段有序 else: if nums[mid] target nums[right]: left mid 1 else: right mid - 1 return -1旋转数组之所以还能做到 O(log n)是因为它保留了“每一段内部有序”这个结构。当你知道mid落在哪一段有序区间你就能复用第 2 部分的“外推逻辑”在有序段内比较中点可以排除半个候选集。它给我们的启发是二分搜索需要的不是全数组严格有序而是每一轮循环面对的候选区间必须存在可用的单调性。全有序是这种单调性的最强形态分段有序是弱化版但只要有分治就能跑起来。4. 写二分时的典型翻车点本质上都是在破坏“每轮可排除”的单调性很多人在实际写二分时踩坑以为自己背错了模板其实更深层的原因是某个分支导致区间没有严格缩小或是边界判断破坏了循环不变量。这里集中说四个最常见的坑全都跟有序性的边界有关。4.1 死循环为什么区间必须在每一轮变小看这段错误代码while left right: mid (left right) // 2 if arr[mid] target: left mid # 错误没有排除 mid 本身 else: right mid当left 1 right且arr[mid] target时left mid并没有让 left 前进下一轮mid (left right) // 2还是同一个值循环就会在“left 和 right 相邻”的状态下无限打转。正确的收缩应该确保每一轮要么排除 mid要么排除包含 mid 的一整侧区间长度必须严格减小。经典闭区间写法里arr[mid] target时必须令left mid 1因为arr[mid]本身已经确认小于 target不可能是答案了留着它只会阻碍终止。类似地arr[mid] target时必须令right mid - 1。这个坑的本质是你违背了“比较结果用于排除而不是用于保留”的原则。保留一个已经确认不符合目标的值就是在维持一个永不缩小的子区间有序性再强也救不回来。4.2 取中点溢出与切片拷贝工程细节如何毁掉对数复杂度取中点最稳妥的写法是mid left (right - left) // 2而不是mid (left right) // 2。在 C、Java 这类语言里当left right超过整型上限时会溢出变成负数mid 直接算错。left (right - left) // 2避免了加法溢出结果等价属于有经验的开发者必写的防御性代码。另一个隐蔽的坑出现在 Python 递归写法里。很多人图省事这样写def binary_search(arr, target): mid len(arr) // 2 if arr[mid] target: return True if arr[mid] target: return binary_search(arr[mid1:], target) # 切片每次复制 O(n) return binary_search(arr[:mid], target)功能上没错但arr[mid1:]和arr[:mid]每次都会生成新数组复制 n/2 个元素总开销是 O(n log n)把二分搜索的时间复杂度彻底败坏掉了。正确做法是用索引参数left和right传递区间不复制数组。经验二分搜索的 O(log n) 建立在“每次只在常数时间内收缩区间”之上。任何在循环或递归里引入 O(n) 操作的做法都会让算法退化成事实上不如线性扫描的代码。排查性能问题时先看你的边界更新和传参方式。4.3 开闭区间不一致left 和 right 越过彼此却没察觉有人混用左闭右开和闭区间规则比如初始化right len(arr) - 1闭区间循环条件用while left right左闭右开风格更新时又用right mid - 1闭区间风格。当数组只有一个元素时left right 0循环根本不会进入直接漏判目标。没有一种写法是“绝对正确”的但你必须全套统一。我推荐两个固定配方闭区间版left 0right n-1while left rightleft mid 1right mid - 1。左闭右开版left 0right nwhile left rightleft mid 1right mid。左闭右开版在找“第一个满足条件的位置”这类问题上特别顺手因为循环结束时left right天然就是边界位置。但无论选哪个初始化、循环条件、边界更新三个地方必须用同一套语义这是杜绝边界错误的第一原则。4.4 重复元素有序性的“连续性推论”如何帮你找第一个和最后一个有序数组还带来一个常被忽视的推论相同的元素必然连续聚在一起。这一条在处理“查找第一个等于 target 的元素”或“最后一个等于 target 的元素”时很有用。如果只是找到任意一个等于 target 的位置用第 1 部分的普通写法就够了。但面对重复元素时普通写法返回的 mid 不一定是第一次出现的位置。要定位左边界可以用def lower_bound(arr, target): left, right 0, len(arr) while left right: mid left (right - left) // 2 if arr[mid] target: left mid 1 else: right mid return left # 第一个 target 的位置注意分支变成了if arr[mid] target: left mid 1 else: right mid。当arr[mid] target时我们并不把 mid 排除而是让右边界收缩到 mid保留它作为潜在的首个目标位置。为什么可以放心向左收缩因为重复元素连续这个有序性推论保证最左边的 target 不会出现在右侧区间之外的任何地方我们只需要慢慢把右边界往左推就能逼近左端。“最后一个等于 target”则是upper_bound - 1或者用类似对称写法。这个场景是最容易让新人和老手都翻车的地方因为它的边界收缩不像存在性查找那么直观但背后的依据依然是数组有序而且比“单调”更进一步用到了单调序列里相等元素区块化的性质。下面把常见问题和修复方案汇总一下问题症状根因修复死循环程序超时left/right 长期相同left mid 未排除中点改为 left mid 1中点上溢mid 算成负数或错误值left right 溢出用 left (right - left) // 2递归切片内存暴涨、耗时非线性切片复制数组用索引区间传参开闭区间混用单元素数组漏判初始化和循环条件语义不一致统一闭区间或左闭右开写法重复元素返回位置不对找到的不是第一次/最后一次出现普通查找不区分左右边界用 lower_bound / upper_bound5. 二分思想的真正边界只要“判定结果”在一维上单调就能二分把二分搜索理解成“只在有序数组里查数”眼光就窄了。二分的本质是对一个具有单调性的判定问题反复折半逼近。数组有序是这种单调性最直白的形式但绝不是唯一形式。5.1 二分答案单调的判定函数取代有序数组有一类题数组本身乱序但我们知道答案在某个数值范围内判定函数ok(mid)具有单调性——mid 越大越可能满足条件或者反过来这时就能用二分缩小答案区间。典型例子给定总耗时和任务量求“至少多大吞吐量能完成所有任务”给定一段连续子数组求“满足和不大于某个值的最短长度”。套用的就是同一个骨架lo, hi 0, max_value while lo hi: mid (lo hi) // 2 if ok(mid): hi mid else: lo mid 1你可能会问判定函数ok(mid)是不是有序数组不是。但它产出的布尔结果随 mid 单调变化形成一条“假假假真真真”的隐式序列。二分搜索要求有序目的就是让这条布尔序列具备单调性。所以提到“二分要求数组有序”时真正的要求是候选空间上存在单调的结构数组只是最常见的一种载体。5.2 有序二维矩阵里的搜索把单调性从一维延伸到多维再看一个二维场景。矩阵的每一行递增每一列也递增比如1 4 7 11 2 5 8 12 3 6 9 16这种矩阵没有全局的逐行顺序但存在“行内单调 列内单调”的局部有序。经典做法是从右上角开始当前值大于 target 就向左移动当前值小于 target 就向下移动时间复杂度 O(m n)。如果每行都做了全局排序还可以把问题归约为在“最后一个小于 target 的行”里做行内二分。这些算法能工作依赖的还是单调性只是从一维折半变成了二维折半。5.3 什么时候不要用二分我也见过不少人把二分用在不该用的地方判断标准很简单如果ok(mid)的布尔结果随 mid 变化不单调二分就直接失效。比如判定函数在中间有一段“真真假真”的波动二分缩小了一半区域仍然可能丢掉正确答案这时候必须回到遍历或排序后重设计判据。另一个陷阱是区间端点选择hi 设置得不够大导致所有 mid 都返回 false最终答案错在边界或者 hi 设置得太大log 轮数虽少但每轮ok(mid)的代价极高整体复杂度未必优于直接枚举。养成习惯用二分前先写一行注释“为什么这个判定函数是单调的”写不出来就说明你不该用二分。我自己的习惯是遇到任何看起来像“在一堆候选答案里找最值”的问题先试着把候选答案排序再为每个候选值设计一个 O(1) 或 O(n) 的判定函数如果判定结果单调就有二分空间。这套流程帮助我把很多看似复杂的题目简化成“排序 二分答案 贪心判定”的三段论比死记模板可靠得多。回到最开头的问题二分搜索要求数组有序根本原因不在于“需要比较大小”——任何查找都需要比较大小——而在于有序性让一次比较的结果能够产生全局推断力。往小了说这个特性保证了二分 O(log n) 的效率并让它能找到最优解往大了说它揭示了二分思想普适性的来源任何具有单调结构的候选空间都值得被折半对待。理解了这一层二分搜索就不再是那个需要背诵的模板而是你分析问题时手里的第一把尺子。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →