ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

二分查找的二段性思维:从旋转数组到峰值查找的通用解法

二分查找的二段性思维:从旋转数组到峰值查找的通用解法 正常拿到一个“旋转数组”“峰值查找”这类题大多数人第一反应是数组不是全局有序的二分还能用吗我直接说结论能用。二分查找从来不需要数组全局有序它真正依赖的是一个被很多人忽略的性质——二段性。只要你找到了那个“分界点”左侧满足某种性质、右侧不满足你的二分就不会失效。这篇文章就把“二段性”这件事掰开揉碎讲清楚重点是帮你把二分从“有序数组中找目标值”的固定套路升级成“找满足条件的最左/最右位置”的通用武器最后落到极值问题上包括旋转数组最小值和峰值查找的完整解法、边界处理、死循环排查。不管你是面试前突击还是在工程里写排查脚本、做监控阈值分析这套思路都能直接套用。1. 先搞清楚普通二分为什么“不够用”1.1 从有序数组中找目标说起大多数人学二分都是从“在一个有序数组里找一个数”开始的。教科书上的标准写法是这样的def binary_search(nums, target): left, right 0, len(nums) - 1 while left right: mid (left right) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 else: right mid - 1 return -1这个写法本身没问题但它容易让人形成一个错误认知二分必须建立在“数组有序”这个前提下。一旦数组不是全局有序比如旋转数组[4,5,6,7,0,1,2]很多人第一反应就是“这题用不了二分”。这种想法是把“有序”这个充分条件当成了必要条件。实际上二分真正依赖的是你能通过mid位置的判断确定性地丢掉一半搜索空间。数组有序只是帮你实现这个目标的一种特例。如果数组的任意前缀或后缀存在某种可以一致性判定的性质那么“丢一半”照样成立。我还见过不少人在二分里写了while left right加上left mid 1、right mid - 1的固定搭配然后遇到变体题就套不进去。原因在于标准写法里用“比较nums[mid]和target”来缩小区间到了极值问题里你要比较的对象变了缩放的规则也变了套模板当然会卡住。这就像你会开自动挡不代表换一台手动挡你就能直接上路。1.2 二段性二分真正依赖的性质所谓“二段性”通俗讲就是存在一个分界点使得数组或某个答案空间在分界点的一侧全部满足性质 P另一侧全部不满足性质 P。注意这里不要求两侧各自有序只要求“某侧统一满足、另一侧统一不满足”。我举几个例子帮你感受一下一个升序数组判断nums[i] target一旦从某个位置开始成立后面就全都成立。这是一个典型的二段性。旋转数组[4,5,6,7,0,1,2]你拿nums[mid]跟nums[right]比较会发现左右两侧的性质也是可区分的左段都大于等于nums[0]右段都小于等于nums[-1]。这个性质在旋转点处发生跳变。一个先升后降的序列比如[1,2,3,2,1]判断“当前元素是否比下一个元素大”这个条件也不是全局统一的但在峰值两侧分别成立。这正是峰值问题可以用二分的核心。生活化一点就像一条流水线上有两台包装机前半段的产品都被打上了红色标签后半段都是蓝色标签。你不需要看完全部产品只要随机抽一个点看一眼颜色就能确定分界点在这个点左边还是右边——因为你拿到的信息具有“一侧统一、另一侧统一”的属性这就是二分能工作的底层原因。到了极值问题里二段性就更明显了。找“极小值”“极大值”这类题目序列往往是 V 形或倒 V 形判断当前点“是在峰顶左边还是右边”本质上就是一个分界判定。你不需要知道整个序列长什么样只需要能回答“当前位置处于哪一段”二分就能像拨动天平一样快速收敛到那个转折点。2. 两类典型的“二段性极值”问题2.1 旋转数组最小值抓住分界点在左还是右先来看最经典的场景一个原本升序的数组在某个位置被旋转了比如[3,4,5,1,2]让你找最小值。这个数组并不全局有序但它的最小值恰好是左右两段的“分界点”左段[3,4,5]都大于等于右段[1,2]的开头。判定逻辑可以这样设计取mid如果nums[mid] nums[right]说明mid落在左段最小值在mid的右侧于是left mid 1反过来如果nums[mid] nums[right]说明mid落在右段最小值在mid的左侧或就是mid本身于是right mid。def find_min(nums): left, right 0, len(nums) - 1 while left right: mid (left right) // 2 if nums[mid] nums[right]: left mid 1 else: right mid return nums[left]这段代码里有个细节很多人会忽略为什么比较nums[mid]和nums[right]而不是nums[mid]和nums[left]因为nums[left]在右段可能大于左段的最小值用它做锚点容易误判而nums[right]永远落在右段拿它做参照系更稳。这是实操中反复踩坑后沉淀下来的经验建议直接记住结论。另一个变种是允许重复元素的旋转数组比如[2,2,2,0,1]。这时会出现nums[mid] nums[right]的情况。如果相等你无法判断mid到底在左段还是右段稳妥的处理是让right - 1把不确定的元素挤掉循环继续。这个操作不会丢掉最小值最坏情况退化成线性扫描。def find_min_with_duplicates(nums): left, right 0, len(nums) - 1 while left right: mid (left right) // 2 if nums[mid] nums[right]: left mid 1 elif nums[mid] nums[right]: right mid else: right - 1 return nums[left]很多人问我为什么这道题“有重复元素”会让二分变棘手。本质上是的情况破坏了二段性判定条件——你无法唯一判断分界在哪一侧。这时候别硬扛用right - 1把不确定性消化掉这是工程里常用的容错思路。2.2 峰值查找为什么无序数组也能二分峰值查找的经典描述是一个数组中nums[i]比左右相邻元素都大称为峰值要求返回任意一个峰值。传统印象里无序数组找极值应该是线性扫描但实际上完全可以用二分因为相邻元素的比较天然构成了二段性。核心思路是取mid比较nums[mid]和nums[mid 1]。如果nums[mid] nums[mid 1]说明你正处在上升段峰值一定在mid的右侧把left移动到mid 1否则nums[mid] nums[mid 1]说明你正处在下降段或恰好是峰顶峰值在mid左侧或就是mid把right移动到mid。def find_peak(nums): left, right 0, len(nums) - 1 while left right: mid (left right) // 2 if nums[mid] nums[mid 1]: left mid 1 else: right mid return left这段代码能跑取决于题目保证nums[-1] nums[n] -∞或者数组边界足够小。因为序列两侧都是负无穷一个“先升后降”的结构必然存在峰值。你从中间切一刀总能根据“当前是在上山还是下山”判断峰值方向这正是二段性的体现。不少初学者会困惑无序数组为什么也能二分我用一个生活类比来解释想象你在爬一座云雾里的山你看不到山的全貌但只要你低头看脚下的坡度——是在往上还是往下你就能判断峰顶在哪个方向。你不必知道整座山的形状只需要知道当前脚感这就是“局部信息 二段性”带来的威力。3. 一套通用的“二分找分界点”模板3.1 模板怎么写check 函数 收缩方向我个人在做极值、边界、分界点类问题时已经不太用“找 target”的经典二分写法了。我习惯用一个更通用、更不容易出错的模板核心是三个部分一个check(mid)判定函数一个收缩规则和一个边界处理习惯。def binary_search_bound(nums): left, right 0, len(nums) # 左闭右开区间 while left right: mid (left right) // 2 if check(mid): # mid 满足 P分界点在右侧或就在 mid left mid 1 else: # mid 不满足 P分界点在左侧 right mid return left这个模板解决的是“最后一个满足条件的索引”问题。如果题目要的是“第一个满足条件的索引”只要把if里的分支逻辑反过来def binary_search_first(nums): left, right 0, len(nums) while left right: mid (left right) // 2 if check(mid): right mid else: left mid 1 return left这两个模板的差别说白了就是在“满足条件时mid还能不能要”。第一个模板把满足条件的元素当成可跳过的所以往右收缩找的是最后一个第二个模板把满足条件的元素保留下来继续试探左侧找的是第一个。能理解这一点你写二分就不需要背几十种变体了。check(mid)怎么写回到二段性你想找什么分界就把“分界一侧统一成立的性质”写成判定条件。比如旋转数组最小值里check(mid)是nums[mid] nums[right]判定的是“mid 是否仍然在左段”峰值查找里check(mid)是nums[mid] nums[mid1]判定的是“当前是否处于上升段”。模板只是骨架真正的灵魂在check函数怎么定义。3.2 模板能用和不能用的边界我见过很多“拿着二分锤子看什么都是钉子”的人这是另一类问题。二分模板不是万能的。它能用的前提仍然是这个序列或答案空间具备二段性。一个随机数组不存在“一侧统一满足、另一侧统一不满足”的任何性质你写check就是一句空话。举几个不能用二分的例子一个完全随机无规律数组让你找最大值。不存在任何一个二元判定能让两侧区分开。寻找字符串中某个指定的无序模式这种情况你需要的是哈希或线性比较而不是二分。动态变化中的序列每次查询都改变数组结构二分的check不具备闭包性质。判断一个场景是否适合二段性二分我的习惯是问自己三个问题是否存在一个分界点使得两侧性质不同对于任意mid能不能通过局部信息确定分界点在左还是右这个判定是否在每次运算中保持一致不依赖修改后的全局状态如果三个答案都是“是”你就可以放心用二分。如果答案不确定别勉强套模板先退回去分析数据分布。再补充一个常见的变体答案二分法。有一些问题不是直接对数组做二分而是对“答案的可能取值”二分每轮用check(mid)判断当前答案是否可行再决定收缩方向。比如“在给定资源量下能否完成任务”的判定如果资源量单调递增、可行性也单调变化那就具备二段性。这种思路在工程上的调度、预算分配问题里很常见本质上还是本文的核心先找到那个二分的“性质断面”再套模板。4. 实操中的边界、死循环与排查经验4.1 left 和 mid 的三种配合关系写二分最痛苦的其实是死循环和数组越界。我自己刚写二分的头两年也经常被mid停在原地不动的问题折磨。后来我总结出一个规律死循环的本质是当区间长度缩小到 1 时mid无法改变 left 或 right。常见的三种组合写法模式适用场景风险点left mid 1right mid查找第一个满足条件的位置如果mid下取整配合left mid 1是安全的left midright mid - 1查找最后一个满足条件的位置mid必须上取整否则可能死循环left mid 1right mid - 1经典查找目标值退出条件left right无死循环这里最坑的是第二种当left和right相邻时(left right) // 2等于left如果你再把left mid赋值回去left永远不会前进就死循环了。解决办法是让mid向上取整mid (left right 1) // 2。我建议你记住一个口诀只要代码里出现了left midmid 就必须向上取整只要出现了right midmid 可以向下取整。用while left right还有个额外好处循环退出时left right你不需要纠结该返回 left 还是 right。如果用的是while left right退出后还要确认“当前索引是否越界、是否满足条件”多一层心智负担。我现在的习惯是默认写左闭右开或者left right的版本除非有特别明确的目标查找需求。4.2 常见问题速查表从“变死循环”到“错一个下标”整理了一份我在实际写题和给团队做 code review 时反复遇到的问题表。你可以直接收藏遇到症状先对号入座。症状根因解决方案程序卡住不退出left mid配合了下取整 midmid 改为(left right 1) // 2结果比预期大一位置分界点判定条件写反了仔细确认check(mid)成立时分界点在左还是右结果比预期小一位置收缩时把mid排除在外了如果mid可能就是要找的答案收缩时改为right mid或left mid数组越界访问nums[mid 1]时 mid 已是末尾循环条件改为left rightmid 取不到右边界旋转数组重复元素死循环nums[mid] nums[right]无法判断用right - 1挤掉重复值mid计算溢出C/Java直接(left right) // 2在极端大数时溢出改成left (right - left) // 2浮点二分退化成死循环用left right判定浮点数改用迭代次数固定循环比如执行 60 次或hi - lo 1e-7这个表里我最想强调最后一条浮点二分和整数二分的写法完全不同。整数二分可以把区间缩到连续的两个整数浮点二分不行浮点的精度决定了你不能用left right这种整型思维。工程里做浮点二分我的习惯是固定迭代 60 次或者设一个足够小的 epsilon比如1e-7因为浮点运算本身有误差试图把区间缩到“两个相邻浮点数”没有意义。排查看不出问题的时候我还有一个笨办法在while循环里把left、right、mid、check(mid)这四个值全部打印出来手动走两三轮。很多看似玄学的死循环其实一眼就能看出来是mid陷入了原地踏步。相信我这比空想快得多。5. 从算法题到工程实践二段性思维的下沉5.1 真实场景一日志时间戳、监控阈值、数据库索引很多人学算法最大的困惑是“这玩意儿工作中用不上”。其实二段性思维在工程里非常常见只是换了一身马甲。第一个典型是日志时间戳查找。假设你有一份按时间递增追加的日志文件现在要查2025-06-01 12:30:00之后的第一条日志。日志文件大到几 GB不可能线性扫描但时间戳天然有序这就是最朴素的二段性二分。跳着读块、读几条判断时间边界、再继续缩一套标准二分就完成了。第二个是监控阈值分析。我做服务稳定性排查时经常要定位“从哪个时刻开始某项指标开始持续超过警戒线”。如果指标序列是平稳的只有某个时间点后突增那么“是否超阈值”这个判定就具备二段性——之前恒为假、之后恒为真。用二分定位突变点比遍历几千个点快得多也能从分钟级定位收敛到秒级定位。第三个是数据库索引。B 树索引的查找过程本身就是一种多路二分而在分库分表场景下按范围定位数据所在的分片本质上就是在对“分片边界数组”做二段性二分。你可能不会在业务代码里手写二分但你会跟这些底层机制打交道。理解了二段性你在设计分片规则、做数据冷热分离时思路会清晰得多。5.2 真实场景二浮点精度、答案二分、资源分配再来说说“答案二分”在工程里的实践。有一类项目需求是这样给定一批任务和固定资源量想知道在某个资源上限下任务能否在 SLA 内全部完成。这个“能否完成”随着资源量上升从 false 变成 true存在一个临界点。你可以对资源量做二分每轮跑一次预估判断最后找到最小可行资源量。这就是典型的答案二分也是二段性思维在成本估算中的应用。我之前在做一个批量定时任务的资源预估时就用了这套思路。任务数量多、调度窗口紧为了不浪费过多资源我先设一个下界和上界然后用二分去逼近“刚好能跑完任务”的并行度。每轮判断逻辑是按当前并行度算总耗时是否低于 deadline。因为并行度与耗时是单调的二段性天然成立所以二分收敛很快实际效果也稳定。浮点二分的另一个工程场景是参数调优。比如某个阈值参数t会影响缓存命中率命中率随t单调变化求最优t时就可以用浮点二分。需要注意的就是我刚才说过的循环用迭代次数控制别依赖left right更别在浮点上比较相等。误差控制在可接受范围内就停。有一点必须提醒二段性二分虽然好用但过度抽象会让代码可读性变差。我在团队里要求使用二分时必须在check函数旁加注释说明“满足该条件意味着分界点在哪个方向”否则后续维护的人根本看不懂。这个细节比算法本身的技巧更影响工程协作效率。我个人在实际操作中的体会是二段性并没有想象的那么玄它真正有用的地方在于帮我把问题抽象成一个“性质分界”的模型。每次拿到看似无序、无规律的数据我都会先问一句能不能找到一个二元判定让一侧全部成立、另一侧全部不成立如果能二分就是我的首选解法。最后再分享一个小技巧凡是你能用线性扫描做但觉得慢的极值、边界问题先别急着优化拿张纸画一下数据分布如果分布呈分段趋势多半就藏着二段性——这时候再套模板你就能少走很多弯路。
返回列表