1. 一道看起来"不守规矩"的题:无序数组里凭什么敢二分
第一次见到"二分法寻找峰值"这个题的时候,我的第一反应是抗拒的。二分查找的前提不就是数组有序吗?现在给我一个完全无序的数组,让我在里面找峰值,还要 O(log n),这不是在为难人吗?
后来把这道题真正吃透,才发现自己之前对二分的理解一直停留在表层。二分法寻找峰值这件事,恰恰暴露了二分最本质的一面:它从来不是因为"数组有序"才能用的,它依赖的是一个比有序弱得多的条件——在任何一步上,我都能判断出"答案在哪一边"。有序只是让这个判断变得最直观、最常见的一种特例而已。
这个概念一旦站住,你会发现一大批看起来"不该用二分"的问题都能用二分解:旋转数组找最小值、山脉数组找峰顶、二维矩阵找局部极大、甚至某些单调性藏在导数里的优化问题。它们的共同点是——不需要全局有序,只需要局部可判定方向。
这篇内容适合两类人看:一类是正在刷算法题、被"为什么无序也能二分"卡住的,另一类是写业务代码时偶尔需要在一段数据里快速定位"局部最高点"的。前者能拿到完整的推理链和代码模板,后者能拿到性能判断和边界处理的实际建议。中间我会把正确性证明的口述版本、四种语言的写法对照、以及我自己踩过的五个坑都摊开讲,你可以直接抄。
1.1 先把峰值的定义钉死,边界到底算不算峰
很多人写这题出错,不是算法想错了,是定义没抠清楚。
峰值的标准定义是:一个元素nums[i]是峰值,当且仅当它严格大于左右相邻的元素。也就是nums[i] > nums[i-1]且nums[i] > nums[i+1]。
那第一个元素和最后一个元素怎么办?它们的左边或右边是空的。绝大多数题目会用一句话兜底:假定nums[-1] = nums[n] = -∞。这句话不是随便加的,它保证了数组里至少存在一个峰值——因为如果你从下标 0 开始一路往右走,只要还在上升就继续走,走到某一步必然要么开始下降(那就是峰),要么走到末尾(末尾比"右边的负无穷"大,那也是峰)。这个"一路往右走"的图像,其实就是后面二分算法的灵魂。
把定义钉死之后,还有两个高频歧义要先排掉:
- "严格大于"意味着相邻元素相等时,两个都不算峰。如果数组是
[1, 2, 2, 1],那么下标 1 和 2 都不是严格峰值,这个数组在严格定义下没有峰。而很多题目会额外保证nums[i] != nums[i+1],就是为了绕开这个麻烦。 - 返回任意一个峰就行,不要求最大峰。这是最关键的一句话。题目说"返回任何一个峰值所在位置即可",意味着我们不需要找全局最大值,找到任何一个局部极大点就交差。这个宽松条件正是 O(log n) 能成立的前提——如果要找全局最大值,在无序数组里你无论如何都得扫一遍。
提示:做题前先确认两件事——相邻元素是否可能相等、返回任意峰还是特定峰。这两点决定了你的代码要不要额外处理平台区,以及能不能直接用最简模板。
1.2 二分真正依赖的不是有序,而是"可判定方向"
我们来做一个思想实验。假设当前搜索区间是[lo, hi],我取中点mid,然后比较nums[mid]和nums[mid+1]。只有两种结果:
情况一:nums[mid] < nums[mid+1]。这说明从mid往右是在爬坡。那么从mid+1出发一路往右走,会发生什么?要么一直爬升到数组末尾,那末尾就是峰(因为它比左边大,右边是负无穷);要么中途某个位置开始下降,那个转折点就是峰。无论哪种,峰值一定存在于[mid+1, hi]这个右半区间里。注意这里说的是"存在",不是"唯一",右边可能有好几个峰,但至少有一个,我们抓一个就够了。
情况二:nums[mid] > nums[mid+1]。这说明从mid往右是在下坡。同理往回推,从mid出发往左走,要么一路爬升到下标 0(那 0 就是峰),要么中途转折(转折点就是峰)。所以峰值一定存在于[lo, mid]这个左半区间里。注意这里右端是mid而不是mid-1,因为mid自己就可能是那个峰,丢掉它就错了。
你看,整个过程我没有用到"数组整体有序"这个条件,我用到的只有一件事:知道相邻两个元素的大小关系,就能确定峰在左边还是右边。这就是"可判定方向"。有序数组之所以能二分,是因为"目标值和nums[mid]的大小关系"正好提供了这个方向判断;而无序数组里,"nums[mid]和nums[mid+1]的大小关系"提供了另一种方向判断。方向判断的来源变了,二分的骨架完全没变。
这个视角的迁移价值很大。以后遇到任何"能不能二分"的疑问,不要问"数组有序吗",而要问"我能不能设计一个谓词,让它在答案左侧全为假、右侧全为真,或者反过来"。只要能,二分就能上。
1.3 和"三分法求极值"的分工,别把两者混着用
这里必须澄清一个高频混淆点:很多人一听"找峰值"就想到三分法,然后写出一个复杂又慢的版本。
三分法解决的是另一类问题:在一个严格单峰(先升后降)的连续或离散区间上,求那个唯一的极值点。它的做法是每次取两个三等分点m1、m2,比较f(m1)和f(m2),然后砍掉三分之一。复杂度是 O(log_{1.5} n)。
两者的区别在于适用前提和目的:
| 对比维度 | 爬坡二分(比较相邻) | 三分法求极值 |
|---|---|---|
| 对数据的要求 | 只需相邻元素不等,允许有多个峰 | 要求区间上严格单峰,峰唯一 |
| 求什么 | 任意一个局部峰值 | 那个唯一的极值点 |
| 每次迭代比较次数 | 1 次 | 2 次(或 1 次近似版本) |
| 每次缩小的比例 | 约 1/2 | 约 1/3 |
| 常数与实现难度 | 小,实现极简 | 稍大,浮点场景还要控精度 |
结论很明确:离散数组上找任意一个峰,用爬坡二分;连续函数或严格单峰序列上求极值,用三分法。前者收敛更快(每次砍一半对砍三分之一),代码也短得多。只有在题目明确要求"峰顶唯一"且你需要严格定位那个极值的时候,三分法才有它的位置。
另外提一句,三分法在浮点数场景下还有个经典问题:当区间长度小于精度阈值时要停下来,否则会陷入无限循环。而爬坡二分全是整数下标运算,lo < hi这个条件天然保证收敛,不会有精度问题。这也是我更偏爱爬坡二分的原因之一。
2. 把思路落成代码:从暴力对拍到四种写法对照
思路能讲通是一回事,写出来不越界、不死循环是另一回事。这一节我把从暴力到二分的完整演进过程摊开,包括每种语言里那些只在特定语言才会踩的坑。
2.1 先写暴力版,不是为了提交,是为了对拍
我个人的习惯是,任何二分题都先花三十秒写一个 O(n) 的暴力版本。理由很简单:二分写错了,报错信息往往是"答案错误"或者"超时",你根本不知道错在哪一步;但有了暴力版本,你可以用随机数据对拍,一秒钟定位问题。
暴力版按定义直译就行:
def brute_peak(nums): n = len(nums) for i in range(n): left = nums[i - 1] if i > 0 else float('-inf') right = nums[i + 1] if i + 1 < n else float('-inf') if nums[i] > left and nums[i] > right: return i return -1注意这里用float('-inf')模拟边界外的负无穷,这是最贴合题目定义的做法。有些同学喜欢用-2**63这类极小整数代替,理论上也行,但如果数组里真的出现了那个量级的数(比如某些压缩后的传感器数据),判断就会失效。用真正的负无穷更稳妥。
顺带说,暴力版本身在真实业务里也不是没用。当n只有几千的时候,线性扫描的顺序访问对 CPU 缓存极其友好,实测经常比二分还快——这个反直觉的结论我在第 5 节会展开。
2.2 爬坡二分的标准写法,三种语言对照
核心代码短到只有六行,但每一行都有讲究。先看 Python:
def find_peak_element(nums): lo, hi = 0, len(nums) - 1 while lo < hi: mid = lo + (hi - lo) // 2 if nums[mid] < nums[mid + 1]: lo = mid + 1 # 右半区必有峰 else: hi = mid # 左半区(含 mid)必有峰 return loJava 版本注意两点:数组长度用.length(不带括号),mid必须用lo + (hi - lo) / 2而不是(lo + hi) / 2:
public int findPeakElement(int[] nums) { int lo = 0, hi = nums.length - 1; while (lo < hi) { int mid = lo + (hi - lo) / 2; if (nums[mid] < nums[mid + 1]) { lo = mid + 1; } else { hi = mid; } } return lo; }C++ 版本里nums.size()返回的是size_t,无符号类型,直接和int混用会产生隐式转换警告,建议一开始就强转:
int findPeakElement(vector<int>& nums) { int lo = 0, hi = static_cast<int>(nums.size()) - 1; while (lo < hi) { int mid = lo + (hi - lo) / 2; if (nums[mid] < nums[mid + 1]) lo = mid + 1; else hi = mid; } return lo; }Go 版本则要留意切片长度为 0 的情况,虽然题目通常保证n >= 1,但自己写工具函数时加个防御更省心:
func findPeakElement(nums []int) int { lo, hi := 0, len(nums)-1 for lo < hi { mid := lo + (hi-lo)/2 if nums[mid] < nums[mid+1] { lo = mid + 1 } else { hi = mid } } return lo }四种写法骨架完全一致,差异只在语法层面。真正需要你记住的只有两个细节:mid的计算方式,以及hi赋值时到底带不带-1。下一小节专门讲这两点。
2.3 循环不变量的口述版本,我每次都这么自检
写二分最怕的是"看起来能跑,某种数据下悄悄出错"。我的自检方式是给循环写一句人话版的不变量,每次迭代结束前在心里默念一遍:
不变量:峰值一定存在于闭区间
[lo, hi]内。
基于这句,检查两件事:
第一,进入循环前,这个不变量成立吗?成立,因为[0, n-1]就是整个数组,而我们已经论证过整个数组必有峰。
第二,两种分支执行后,不变量还成立吗?nums[mid] < nums[mid+1]时,峰在[mid+1, hi]内,我们把lo设成mid+1,区间变成[mid+1, hi],不变量保持。nums[mid] > nums[mid+1]时,峰在[lo, mid]内,我们把hi设成mid,区间变成[lo, mid],不变量同样保持。
第三,循环结束时,区间[lo, hi]缩成什么了?条件lo < hi不成立,说明lo == hi,区间只剩一个点。而不变量说峰在这个区间里,那这个唯一的点就是峰。所以返回lo或hi都对,它们相等。
这套三步自检法我用了很久,几乎能覆盖所有二分变体的正确性验证。你如果养成习惯,写二分的一次通过率会明显提升。
顺带解释为什么不写成while (lo <= hi)。如果改成lo <= hi,当lo == hi时还会进一次循环,此时mid == lo == hi,然后nums[mid + 1]就越界了(mid已经是最后一个下标)。这是最经典的越界来源。
2.4 返回值与收敛点:为什么返回 lo 一定对
有个细节很多人没想透:为什么循环结束时lo和hi一定相等?
看两个分支的更新方式。左边分支lo = mid + 1,因为mid < hi(循环条件保证),所以mid + 1 <= hi,新lo不会超过hi。右边分支hi = mid,因为mid >= lo(mid = lo + (hi-lo)/2向下取整,当hi > lo时mid >= lo),所以新hi不会小于lo。
再看收敛性。每次mid都严格落在[lo, hi)范围内,也就是mid < hi。所以左边分支新lo = mid + 1 > lo,区间左端严格右移;右边分支新hi = mid < hi,区间右端严格左移。两端至少有一端严格前进,区间长度严格减小,必然在有限步内收敛到lo == hi。
这个论证还顺带解释了为什么不会死循环。死循环通常发生在某种分支下区间没有缩小,比如把右边分支写成hi = mid - 1又在mid == lo时触发,这时候hi变成了lo - 1,区间反转,lo < hi依然成立但逻辑已经乱了。第 4 节会详细讲这个坑。
3. 变体地图:从一维到二维,从任意峰到指定峰
掌握基础模板之后,你会发现围绕"找峰"能衍生出一整族题目。它们的共同思路是"把问题转成基础模板,或者把基础模板平移一层"。这一节挑四个最有代表性的讲。
3.1 山脉数组找峰顶:条件是加强版不是新版
山脉数组的定义是:先严格递增,到达峰顶后严格递减。这其实比"任意峰"的条件更强——它保证了峰唯一,且整个数组就是这个形状。
所以直接用基础模板,一行都不用改:
def peak_index_in_mountain_array(arr): lo, hi = 0, len(arr) - 1 while lo < hi: mid = lo + (hi - lo) // 2 if arr[mid] < arr[mid + 1]: lo = mid + 1 else: hi = mid return lo为什么不用改?因为基础模板只依赖"相邻比较",而山脉数组的相邻比较提供的方向判断更精确(全局唯一确定)。条件变强时,原来的算法依然有效,只是保证更强。这是我很喜欢用来自我安慰的一点:只要你的算法在所有无序数组上都对,那它在山脉数组这种特例上必然也对。
真正需要注意的反而是输入校验。如果是写业务代码而不是刷题,山脉数组要先验证是不是真的山脉形状(长度至少 3、峰不在两端、严格单调),否则算法会返回一个看起来合理但没意义的下标。
3.2 山脉数组里找目标值:先定位峰,再两侧二分
这是进阶题:给你一个山脉数组和一个目标值target,返回它第一次出现的下标,找不到返回 -1。
思路分两步,很自然:
- 用上面的模板找到峰顶下标
peak; - 在
[0, peak]这段升序区间上做标准二分,找不到再去[peak+1, n-1]这段降序区间上做反向二分。
def find_in_mountain(arr, target): n = len(arr) lo, hi = 0, n - 1 while lo < hi: mid = lo + (hi - lo) // 2 if arr[mid] < arr[mid + 1]: lo = mid + 1 else: hi = mid peak = lo # 左半段:升序,标准二分 l, r = 0, peak while l <= r: m = l + (r - l) // 2 if arr[m] == target: return m elif arr[m] < target: l = m + 1 else: r = m - 1 # 右半段:降序,比较方向反转 l, r = peak + 1, n - 1 while l <= r: m = l + (r - l) // 2 if arr[m] == target: return m elif arr[m] > target: l = m + 1 else: r = m - 1 return -1注意这里第二段二分用的循环条件是l <= r,和第一段的lo < hi不一样。这不是笔误,而是因为我换了二分的语义:前面的爬坡二分是"区间内必有答案",随便怎么收缩最后剩下的就是答案;这里的查找二分是"区间内可能有答案,也可能没有",必须用<=并在命中时立即返回,才能正确处理"找不到"的情况。
注意:同一份代码里混用两种二分语义是非常常见的错误来源。写的时候在注释里标清楚"这是找峰模板"还是"这是查找模板",能省下大量调试时间。
这个"先找峰再分治"的套路在实际业务里也有对应:比如在一段先涨后跌的指标曲线里,先定位拐点,再在拐点两侧分别做阈值搜索。
3.3 二维峰值:按行取最大,再比较上下行
二维版本的问题是:给你一个矩阵,找一个"比上下左右四个邻居都大"的元素。
直觉上是把一维的思路升级:一维时我比较nums[mid]和nums[mid+1];二维时我能不能比较"第 mid 行和第 mid+1 行的某个代表元素"?
可以。关键是选哪个代表元素。正确做法是:在第 mid 行里找到该行最大值所在列col,然后比较mat[mid][col]和mat[mid+1][col]。
为什么选行最大值?因为行最大值天然比它左右两个邻居都大,所以只要它再比上下之一大,它就是一个完整的峰值。而如果它比下方的元素小,说明"往下走还有更高的东西",峰必然在下方;反之峰在上方(含当前行)。
def find_peak_grid(mat): top, bottom = 0, len(mat) - 1 while top < bottom: mid = top + (bottom - top) // 2 col = mat[mid].index(max(mat[mid])) if mat[mid][col] < mat[mid + 1][col]: top = mid + 1 else: bottom = mid col = mat[top].index(max(mat[top])) return [top, col]复杂度是 O(n log m),其中 m 是行数、n 是列数。每轮迭代要扫一整行找最大值,这是 O(n);迭代 log m 次。如果行数远小于列数,可以把矩阵转置一下让 m 变大、n 变小,总比较次数会更少。这个优化点很多人不知道,但在大矩阵上能省下可观的常数。
二维版本最容易漏的边界是首行和末行——如果bottom = 0退出循环,此时top == 0,是没有"上一行"可以比较的。但因为我们最终只是在第top行取行最大值返回,不再做上下比较,所以不会越界。这个设计恰好绕开了边界判断,是这道题很优雅的地方。
3.4 允许相邻相等的平台地形,怎么改才不死循环
前面所有代码都依赖一个隐含前提:相邻元素不相等。一旦数组里出现连续相等的一段(我叫它"平台"),nums[mid] < nums[mid+1]这个判断在平台上会返回 false,代码会认为"右边在下坡",于是把hi缩到mid。这个行为本身不会死循环,但会带来一个语义问题:平台上的元素按严格定义不是峰,算法可能停在一个平台上,返回一个不符合严格定义的下标。
我实际遇到过这类数据:某段传感器采样因为量化精度问题出现了大段完全相同的取值,用基础模板跑出来一堆"假峰"。
处理方案要看题目到底想要什么。
如果题目允许"非严格峰值"(大于等于左右邻居就算),那基础模板直接能用,不用改。
如果题目要求严格峰值但数据里有平台,我推荐先做一次压缩:把连续相等的段折叠成一个元素,并记录每段的起始下标。然后在压缩后的序列上跑基础模板,最后把结果映射回原数组:
def find_peak_with_plateau(nums): starts = [0] for i in range(1, len(nums)): if nums[i] != nums[i - 1]: starts.append(i) lo, hi = 0, len(starts) - 1 while lo < hi: mid = lo + (hi - lo) // 2 if nums[starts[mid]] < nums[starts[mid + 1]]: lo = mid + 1 else: hi = mid idx = starts[lo] # 压缩后的点可能是平台块,需要验证它是否严格是峰 left = nums[idx - 1] if idx > 0 else float('-inf') right_idx = idx while right_idx + 1 < len(nums) and nums[right_idx + 1] == nums[idx]: right_idx += 1 right = nums[right_idx + 1] if right_idx + 1 < len(nums) else float('-inf') return idx if (nums[idx] > left and nums[idx] > right) else -1这段代码比较长,但它把所有歧义都显式化处理了:什么时候是平台、平台算不算峰、压缩后怎么映射回去。写业务代码时我一向主张宁可多写五行,也不要留一个"在某种数据下会静默出错"的隐患。
还有一个变体值得提一句:环形数组找峰值(首尾相邻)。这个问题的思路是把环形在某处"剪开"变成线性问题再套模板,或者直接在环上二分、比较nums[mid]和nums[(mid+1) % n]。后者的终止条件比线性版本更微妙,需要额外验证lo == hi时该点确实是峰,我不建议在没想清楚之前硬套模板。
4. 调试实录:五个把 O(log n) 写成死循环或越界的坑
这一节是我最想写的部分。算法原理看一遍就懂,但这些坑是真的会让你在半夜对着屏幕怀疑人生。
4.1 mid + 1 越界与 lo <= hi 的混用
最经典的组合错误:循环条件写成while lo <= hi,分支里又访问nums[mid + 1]。
推演一下。当lo == hi时进循环,mid == lo == hi,如果此时hi已经是n-1,那nums[mid+1]就是nums[n],直接数组越界。Python 会抛IndexError,Java 抛ArrayIndexOutOfBoundsException,C++ 则是未定义行为——有时候不崩,读到一个垃圾值,然后返回一个荒谬的答案,这种最难查。
根因不是"忘了判边界",而是误用了另一套二分语义。lo <= hi属于"查找型二分"(区间内可能没有答案,需要在命中时立刻返回);爬坡二分属于"收敛型二分"(区间内必有一个答案,最后收敛到单点)。两套语义的循环条件天然不同,混用必然出事。
我的做法是:在代码旁边写一行注释,标出这是哪套语义。听起来傻,但确实有效。
4.2 hi = mid - 1 的手滑,会漏掉真正的峰
第二个坑更隐蔽,因为它不会崩,只会给出错误答案。
很多人在写标准二分时把右边收缩写成hi = mid - 1,形成肌肉记忆,写爬坡二分时顺手就写了。问题是:在爬坡二分里,mid自己可能就是那个峰。
举个例子,数组[1, 3, 2]。lo=0, hi=2,mid=1,nums[1]=3 > nums[2]=2,走右边分支。正确写法hi = mid = 1,接着lo=0, hi=1,mid=0,nums[0]=1 < nums[1]=3,lo = 1,循环结束返回 1,正确。而错误写法hi = mid - 1 = 0,循环直接结束,返回 0,而nums[0]=1根本不是峰。
判断标准很简单:如果mid这个位置本身有可能承载答案,收缩时就必须保留它。在爬坡二分里,右边分支保留了mid,左边分支丢掉了mid(因为nums[mid] < nums[mid+1]已经证明mid不是峰),这个不对称是有意为之,不是笔误。
4.3 整数溢出与语言差异
mid = (lo + hi) / 2这个写法在 Java 和 C++ 里是有名的隐患。当lo和hi都非常大时(比如接近Integer.MAX_VALUE),lo + hi会溢出成负数,mid变成负值,随后索引越界。
虽然在"数组下标"这个场景里,lo + hi要溢出需要数组长度达到十亿量级,现实中不太可能,但如果你的二分不是在数组上,而是在某个数值范围上(比如二分答案、二分时间),lo和hi完全可能是10^18这个量级,那就必然会炸。
统一改写成lo + (hi - lo) / 2是最省事的做法,代价为零。Python 不需要(大整数自动扩展),但为了跨语言迁移方便,我建议在所有语言里都用这个写法。
另一个语言差异是 C++ 的size()返回无符号类型。如果你写for (int i = 0; i < nums.size() - 1; i++),当nums为空时,size()返回 0,0 - 1在无符号运算下变成一个巨大的正数,循环条件变成恒真,直接崩。这类问题在开启-Wall -Wextra编译时会给出警告,建议养成看警告的习惯。
4.4 空输入、单元素、全递增与全递减
除了n >= 1这个题目通常给的保证,我在写通用工具函数时必测这四类极端数据:
| 输入 | 期望行为 | 说明 |
|---|---|---|
[] | 返回 -1 或抛异常 | 题目一般不测,但工具函数必须防 |
[5] | 返回 0 | 单元素,左右都是负无穷,它自己就是峰 |
[1,2,3,4,5] | 返回 4 | 全递增,末尾元素是峰 |
[5,4,3,2,1] | 返回 0 | 全递减,首元素是峰 |
这四组数据能覆盖掉绝大部分边界 bug。特别是全递增和全递减这两组——它们让二分永远只走同一个分支,如果那个分支的收缩逻辑写错,立刻暴露。我在本地跑测试时,这四组是每次都要过的"冒烟测试"。
4.5 对拍脚本:用随机数据把边界一次测干净
边界数据是有限的,随机数据是无限的。我的标准流程是:写完二分解法后,用暴力解法做基准,跑十万组随机数据对拍。
这里有个细节要注意:峰值可能有多个,所以不能直接比较两个函数返回的下标是否相等。正确做法是验证二分返回的那个下标,按定义确实是个峰。
import random def is_peak(nums, i): n = len(nums) left = nums[i - 1] if i > 0 else float('-inf') right = nums[i + 1] if i + 1 < n else float('-inf') return nums[i] > left and nums[i] > right def fast_peak(nums): lo, hi = 0, len(nums) - 1 while lo < hi: mid = lo + (hi - lo) // 2 if nums[mid] < nums[mid + 1]: lo = mid + 1 else: hi = mid return lo random.seed(20240607) for t in range(100000): n = random.randint(1, 40) nums = [random.randint(-50, 50) for _ in range(n)] # 消除相邻相等,保证满足题目前提 for i in range(1, n): while nums[i] == nums[i - 1]: nums[i] += random.choice([-1, 1]) idx = fast_peak(nums) if not is_peak(nums, idx): print("对拍失败", nums, idx) break else: print("十万组全部通过")这个脚本我建议你直接存下来,以后所有找峰类题目改一改fast_peak就能复用。对拍比"想清楚"可靠得多——人的推理总有盲区,随机数据没有。
提示:对拍时一定要设随机种子。出了问题能复现,这是调试的基本素养。用时间戳做种子的话,失败一次之后就再也复现不了那个用例了。
5. 复杂度背后的性能真相:什么场景才值得上二分
O(log n) 听起来很美好,但真实工程里它有明确的适用边界。这一节聊聊数字背后的实感。
5.1 O(log n) 的收益在哪一档数据量上才显出来
直观对比一下:数组长度n,二分需要大约log2(n)次比较。
| 数据规模 n | 线性扫描比较次数 | 二分比较次数 | 倍数差距 |
|---|---|---|---|
| 1,000 | 约 1,000 | 约 10 | 100 倍 |
| 100 万 | 约 100 万 | 约 20 | 5 万倍 |
| 10 亿 | 约 10 亿 | 约 30 | 3000 万倍 |
关键洞察是:二分次数的增长极其缓慢。数据量翻一倍,二分只多比较一次。这意味着当你面对的是千万级、亿级的数据,或者需要在一个很大的数值范围内搜索(比如二分答案、二分时间、二分容量)时,二分的优势是碾压性的。
但反过来看这张表的第二列:n = 1000时,线性扫描也就 1000 次比较。一次比较在 CPU 上大概几个纳秒,1000 次大概几微秒。而二分的 10 次比较,每次都可能是一次缓存未命中,实测下来差距远没有 100 倍那么大,甚至在某些情况下二分更慢。
结论:数据量在几千以内,别急着上二分,先测。数据量上到百万级,二分几乎是必选项。
5.2 缓存友好性:二分看起来很酷,但可能更慢
这是我最想强调的一个反直觉经验。
线性扫描是顺序访问。CPU 会预取接下来的缓存行,分支预测器也能很好地预测"继续循环"这个几乎总是成立的分支。现代 CPU 在这种模式下的吞吐能力非常惊人,一个简单的循环每秒处理几亿个元素不是问题。
二分是随机访问。第一次跳到中点,第二次跳到 1/4 或 3/4 处,每次跳的位置和上一次毫无关系。L1 缓存(通常几十 KB)在数组稍大一点的时候完全装不下,每访问一个元素都要去主存拿,一次可能是上百个时钟周期。虽然总共只有三十来次,但每一次都是凉的。
我实测过一个场景:在 10 万个元素的数组里找峰,线性扫描和二分交替跑了多轮。二分虽然比较次数少两个数量级,但实际耗时只快了三到五倍,远没有次数差距那么悬殊。而在 3000 个元素的数组上,两者几乎打平,有时线性还略快。
这不是说二分没用,而是说:优化前先量一下,别让复杂度分析变成纸上谈兵。复杂度告诉你的是增长趋势,不是绝对时间。
5.3 数据流与分段数据的处理建议
实际业务里的"找峰"往往不是一次性拿到完整数组,而是数据分片、分批到达。这时候二分的用法要变一变。
如果数据是一次性加载的,直接套模板,注意提前做边界检查。
如果数据是分成若干块写入的,可以在块级别先做一个"摘要":记录每块的最大值、首元素、尾元素。找全局峰值时,先在块摘要上跑二分定位候选块,再进到那个块里做精确查找。这样能把一次大范围二分拆成"块间二分 + 块内扫描",内存局部性会好很多。这个思路和数据库里的多级索引是同一个逻辑。
如果数据是无界的流式数据,那严格意义上的二分就不适用了——你没有hi。这类场景一般改用"峰值检测"的启发式算法:维护一个滑动窗口,记录窗口内的最大值,当当前值超过窗口最大值时标记为候选峰,当值回落到某个比例(比如候选峰值的 90%)以下时确认这个峰。具体阈值要看业务信号的噪声水平,参数调起来比较靠手感。
还有个实操细节:如果数组元素是浮点数,比较时要小心精度。nums[mid] < nums[mid+1]在两者相差1e-16时可能因为舍入而不稳定,导致算法走向不可预测。稳妥做法是引入一个极小阈值eps,只有当差值超过eps时才认定方向。代价是在真正的平缓区间上可能判断失效,需要结合业务精度要求来定。
注意:所有涉及浮点数极值的场景,都要先想清楚"多小的差异算差异",这一步没想清楚,代码写得再漂亮也是白搭。
我做过的几个带阈值判断的告警模块,都是先跑数据统计出噪声的标准差,再把eps设成两到三倍标准差。纯拍脑袋定阈值,后面必然要被反复调参折磨。