先别急着看模板,我们先聊聊这两道题本身。LeetCode 852的“山脉数组的峰顶索引”和162的“寻找峰值”,放在优选算法这个系列里,特别适合讲清楚一个概念:二分查找不一定要求数组整体有序,它只要求你有一个可以把区间分成两半、并且确定答案在某一半的依据。这个依据,在经典二分里是“和目标比大小”,在这两道题里是“和相邻位置比大小”,本质上都是利用单调性做区间收缩。
标题里说“优选算法-二分查找”,这些年我刷过不下两百道二分题,这两道是我讲课时必选的入门变种。它们不涉及复杂的状态压缩,也不需要推导什么数学公式,却能让学生第一次意识到:原来二分法的威力不止于有序数组,它是一种把问题规模减半的思维工具。全文我会先拆清楚两道题的精髓,再手把手带你把代码写出来,最后把最容易翻车的边界条件和常见坑全部列出来。
1. 整体设计与思路拆解:为什么这两道题放在一起讲
1.1 表面上一个找峰顶,一个找峰值,底层是同一个模型
852题给的是一个“山脉数组”,定义很严格:数组长度至少为3,存在一个峰值下标 i,满足 0 < i < n-1,数组在 i 之前严格递增,在 i 之后严格递减。这个结构像一座山,左边爬坡,右边下坡,山顶只有一个。162题放宽了条件:数组不需要整体呈山形,可能在多个位置出现局部峰值,峰值就是任意一个 nums[i] > nums[i-1] 且 nums[i] > nums[i+1] 的位置,你随便返回一个合法峰值的下标即可。边界上也有宽容处理,比如第0个元素只要大于第1个元素,也可以当作峰值;对数组两端来说,可以认为前后都是负无穷。
两题最核心的相同点是什么?都是通过相邻元素的比较来判定趋势,然后用二分法把搜索区间切掉一半。我打个比方,传统有序数组的二分,像在一本按页码排好的字典里查单词,你通过比较当前页和目标单词的字母顺序,决定往前翻还是往后翻。而“山脉数组”这类题,像你在山里迷路了,不用看地图,只需要看脚下是上坡还是下坡,就能判断山顶在哪一边。前者靠绝对的大小关系,后者靠局部的方向趋势。
1.2 二分法真正的适用范围:只要你能排除一半的搜索空间
很多初学者有一个根深蒂固的误解:二分查找只能用在严格有序或部分有序的数组上。这个认知导致他们在面对非典型题目时完全不敢往二分方向想。我特别喜欢用“排除法”来重新定义二分:只要你能证明答案不可能在左半区间,那它必然在右半区间,反之亦然,就可以放心二分。你甚至不需要提前知道答案的精确位置,只需要能安全地丢掉某一半。
拿852题来说,数组先升后降,那么对于任意一个中间位置 mid,我比较 nums[mid] 和 nums[mid+1]。如果 nums[mid] < nums[mid+1],说明当前这个点还在“上坡段”,峰顶不可能在 mid 左边,答案落在 [mid+1, right] 区间;如果 nums[mid] > nums[mid+1],说明当前点已经在“下坡段”或者正好是峰顶,那答案落在 [left, mid] 区间。这两步判断没有任何遗漏,因为我们不需要知道峰值具体等于多少,只需要知道它在哪一侧。这就是二分思想在“趋势数组”上的推广。
1.3 两道题的教学顺序:由严格到宽松,由易到难
把852放在162前面讲,我认为是合理的教学节奏。严格的山脉数组,条件限定死了,推理路径清晰,学生容易接受;162把“只有一个峰”拓展成“可能有多个峰”,但解法模板几乎没变,学生的成就感会很高,会觉得“原来我掌握的技巧可以直接迁移”。这种由特殊到一般、由强条件到弱条件的安排,比直接上手162再回头补852要顺畅得多。
在“优选算法”这个系列里,我通常还会把69题求x的平方根、34题在排序数组中查找元素的第一个和最后一个位置一并拿出来交叉讲解,因为它们的公共骨架都是“通过一个贝叶斯式的区间排除逻辑,把O(n)的线性扫描降成O(log n)”。二分题目看起来千变万化,但你的思维模板一旦建立起来,剩下的就是套框架+处理边界。
2. 核心细节解析:山峰数组的峰顶索引实操拆解
2.1 严格定义决定了判断方向:为什么和mid+1比较而不是mid-1
852题最经典的一种写法是循环不变量。定义一个区间 [left, right],始终让峰顶候选落在区间内。我们比较 nums[mid] 和 nums[mid+1],这里有个关键细节:mid 必须小于 right,这样 mid+1 才不越界。我当时第一次写的时候就是没注意这个,直接把二分模板里常见的 mid = left + (right - left) / 2 拿过来用,结果 mid 取到 n-2 时 nums[mid+1] 越界,直接在leetcode上报了个数组越界错误,debug了半天才反应过来。
选择nums[mid] < nums[mid+1]而不是nums[mid] < nums[mid-1],原因在于我们想利用“上坡”这一侧的条件。在前半段递增区间,相邻元素差值大于0,后半段递减区间,相邻元素差值小于0。如果你写成向后比较nums[mid-1] > nums[mid],你会发现逻辑要反转,而且 mid 不能等于 left,需要对左边界做特殊处理。两相对比,向前比较的代码更简洁、边界分支更少,所以优先采用前向比较。
2.2 用事实例走一遍完整推理
假设数组arr = [0, 2, 1, 0],肉眼扫一眼就知道峰值是2,对应下标1。我们用二分推导一遍:
初始 left=0,right=3,mid = 0 + (3-0)/2 = 1。比较 arr[1]=2 和 arr[2]=1,2 > 1,说明 mid 已经处于下降段,峰顶不可能在 mid 右侧,所以 right = 1,区间缩为 [0,1]。第二次循环,left=0,right=1,mid = 0 + (1-0)/2 = 0。比较 arr[0]=0 和 arr[1]=2,0 < 2,说明 mid 处于上升段,峰值不可能在 mid 左侧,所以 left = 1。此时 left == right == 1,循环退出,返回1。完全正确。
如果数组是[1, 3, 5, 4, 2],峰值5在下标2。初始 left=0,right=4,mid = 0 + (4-0)/2 = 2。arr[2]=5 和 arr[3]=4,5 > 4,说明 mid 在下降段或已经是峰顶,right=2,区间 [0,2]。接着 mid = 1,arr[1]=3 和 arr[2]=5,3 < 5,说明 mid 在上升段,left=2。返回2。运行到这里你会发现,这个比较策略永远不会把正确答案排除掉,因为我们只在“确定不可能”的时候收缩区间。
2.3 二分模板怎么选:左闭右闭区间的标准写法
我在系列课程里给学生的代码模板是左闭右闭区间,因为它的终止条件清晰,最不容易记混。代码如下:
public int peakIndexInMountainArray(int[] arr) { int left = 0, right = arr.length - 1; while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] < arr[mid + 1]) { left = mid + 1; } else { right = mid; } } return left; }注意几个细节:
- 循环条件是
left < right,不是left <= right,因为我们最终要返回的是下标,而答案一定在区间内,当区间收敛为一个点时这个点就是答案。用left <= right反而要多写一个返回值逻辑,麻烦。 mid = left + (right - left) / 2取的是左中位数。因为当right - left = 1时,mid 等于 left,不会出现死循环。如果你改成mid = left + (right - left + 1) / 2(右中位数),在这个模板里就要小心,right = mid那一步可能导致区间不收缩或逻辑错乱。统一写成左中位数最省心。- 题目需要返回的是索引值,不是元素值,所以不要写
return arr[left],我第一次给同事 review 代码时他就犯过这个错误,结果返回了元素值,用例直接挂掉。
3. 实操过程与核心环节实现:从山峰数组到寻找峰值
3.1 162题:从“唯一的峰”到“任意一个峰”,条件放宽但本质未变
162题最不友好的一点是它没有保证数组是严格递增再递减的,nums可以是一个任意形状的“波浪线”,比如[1,2,1,3,5,6,4],里面既有2这个峰(因为两边是1和1),也有6这个峰。题目说了返回任意一个峰值的下标即可。
我给学生讲这个题的时候,喜欢先问一个反直觉的问题:“如果整个数组是递增的,比如 [1,2,3,4,5],按题目的定义,峰值在哪里?”很多学生答不上来,因为峰值定义是比较左右邻居,而最后一个元素5右边没有邻居,按经典定义它不算峰值。此时再看题目条件——它明文规定,你可以假设 nums[-1] = nums[n] = -∞,也就是说边界外视为无穷小。在这个前提上,数组末尾的5和边界外的负无穷比较,5 > 负无穷,所以5也算峰值,返回下标4即可。同理,如果数组整体是递减的,第一个元素就是峰值。
这个条件的意义在于:它保证了数组里至少存在一个峰值,而且二分查找永远不会出现“答案跑出区间外”的情况。你可以把整个数组想象成一条连绵起伏的山脉,两端是海平面(负无穷),只要你有山,山顶就一定存在。
3.2 为什么nums[mid] < nums[mid+1]时右侧必有峰值:严格逻辑证明
关键在于区间 [mid+1, n-1] 这一段的左端点,有个属性——nums[mid+1]大于nums[mid]。然后我们再看右端点 n-1,它右侧是负无穷,所以它相对右侧边界也具备“右侧比自己小”的属性。现在你从 mid+1 出发,不断向右走,只需要找到一个位置 k 使得nums[k] > nums[k+1],k 就是一个峰值。如果整个右侧段一直递增到末尾,那么末尾元素 n-1 本身就是峰值,因为nums[n-1] > -∞。左边同理。这个逻辑不需要知道中间哪一段上升哪一段下降,只需要保证右侧区间的“最右端一定存在下降折点”。
我用一个更生活化的例子:你站在山腰上,发现脚下的路在向上延伸,那山顶就一定在你前方,因为地平线(负无穷)不会让你永远向上到天上去。反过来,如果脚下在向下走,那山顶就在你身后某个位置。这就是162题的二分逻辑,也是852题第2小节里推理过程的推广。
3.3 完整实现与对比测试
直接看Java实现:
public int findPeakElement(int[] nums) { int left = 0, right = nums.length - 1; while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] < nums[mid + 1]) { left = mid + 1; } else { right = mid; } } return left; }有没有发现,这个代码和852题的代码一字不差?唯一的区别只是题目描述的数组性质不同,但你写出来的判定分支、收缩策略、终止条件完全一样。这也是我强烈建议把两道题连着做的原因:做完852再看到162,你会觉得题目出题人是故意给你送分。
跑几个用例验证:
- 用例1:
[1, 2, 3, 1]。mid = 1,nums[1]=2 < nums[2]=3,left=2;mid = 2,nums[2]=3 > nums[3]=1,right=2;返回2,值为3,是峰值。 - 用例2:
[1, 2, 1, 3, 5, 6, 4]。mid = 3,nums[3]=3 < nums[4]=5,left=4;mid = 5,nums[5]=6 > nums[6]=4,right=5;mid = 4,nums[4]=5 < nums[5]=6,left=5;返回5,值为6,是峰值。注意这里返回的是下标5,但下标1(值2)也是合法峰值,符合题目“返回任意一个峰值”的要求。 - 用例3:
[1, 2, 3, 4]。mid = 1,nums[1]=2 < nums[2]=3,left=2;mid = 2,nums[2]=3 < nums[3]=4,left=3;返回3。数组整体递增,峰值是最后一个元素,符合边界负无穷定义。
3.4 时间复杂度和空间复杂度:从O(n)到O(log n)的收益
很多人第一反应是直接遍历找nums[i] > nums[i-1] && nums[i] > nums[i+1],按峰值的定义线性扫描,时间复杂度O(n),空间O(1)。这当然能过,但意义不大。二分法把时间降到O(log n),当 n 是10亿时,遍历需要10亿次比较,二分只需要30次。这类算法题面试官真正考察的不是你能不能选出峰值,而是你会不会利用趋势来剪枝。
这里顺手记一下空间复杂度:我上面的Java代码只用了一个数组入参,没有额外创建大对象,变量只有left、right、mid三个,所以空间O(1)。所有二分题目基本都是这个套路,优点非常明显。
4. 常见问题与排查技巧实录:边界、死循环和变种陷阱
4.1 为什么我的二分会死循环?
最常见的死循环原因是left = mid而不是left = mid + 1。假设有两个元素时left=0,right=1,mid=0,目标位置在右侧,如果你写left = mid,区间从 [0,1] 变成 [0,1],无限循环。当年我刷852题时偷懒,把if条件里的上下行都写成left = mid,结果leetcode显示Time Limit Exceeded,我还以为是数据太大导致的,后来打断点才发现是死循环。记住一个检查原则:每个分支收缩之后,区间的长度必须严格减少。检查方法很简单,代入right - left == 1的情境,看看左中位数mid = left时,if里是left前进还是right后退,两者必然至少有一个让区间缩小,只要两个分支都不造成区间完全不动就行。
4.2nums[mid + 1]真的安全吗?mid会不会越界到数组末尾?
根据模板,mid = left + (right - left) / 2,且循环条件left < right。我们来证明:当 left < right 时,mid 必然小于 right。因为mid = left + (right-left)/2,而(right-left)/2的最大值是(right-left-1)/2(整数除法向下取整),所以mid <= left + (right-left-1)/2 < left + (right-left) = right,即 mid < right。因此 mid+1 <= right,不会越界。这是左中位数模板配合left < right循环条件的天然保证。如果你用的是右中位数mid = left + (right-left+1)/2,情况反过来,你得保证 mid >= left+1,同时注意和nums[mid-1]的比较才安全。所以一个模板用顺手了不要轻易改取整方向,除非你完全理解边界。
4.3 为什么不建议在二分里同时比较左右邻居?
有的同学觉得峰值既然定义是nums[i] > nums[i-1] && nums[i] > nums[i+1],那干脆写两个判断,满足条件直接return。这种写法不是不行,但会打乱单调性判断的简洁度。我们只需要一个前向比较nums[mid] < nums[mid+1]就已经能确定峰在左还是右。写两个比较反而要处理mid==0或mid==n-1的边界特判,代码更长,出错概率更大。二分的精髓是让每次迭代只依赖一个局部信息,信息越少,推理越稳。这两题的峰值判断本质上是判断“是否处于下降沿”,一个比较足矣。
4.4 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 循环内left不前进 | 收缩分支写成了left = mid | 改为left = mid + 1 |
| 数组越界 | 取右中位数时比较nums[mid+1] | 换左中位数模板,或改为比较nums[mid-1] |
| 返回了元素值而非索引 | 粗心returnarr[left] | 检查返回类型,返回left |
| 单元素数组运行异常 | 未考虑n==1边界 | 在循环前单独判断或确保循环条件cover |
| 觉得结果不一定对 | 没有理解边界负无穷的意义 | 把nums[-1]和nums[n]画成两端的-∞,在草稿纸上跑一遍 |
这几条基本覆盖了这两道题90%的报错。首刷时如果遇到莫名Runtime Error,先按上表排查,不要急着重新读题。
4.5 变种题预热:环形数组和二维峰值
我把这两道题讲完后,常给学生留一个思考题:如果数组是环形的,且不存在相邻相等的元素,能不能用二分找峰值?答案是能,但没152题这么简单,因为环形结构会打破左右端点“边界负无穷”的假设,你需要先破坏环,比如在某一个下降沿处切开,或者用一个旋转数组上的二分处理。另一个变种是“二维峰值查找”,在矩阵里找一个位置,它的值比上下左右都大,这类题可以用行上的峰值筛选加列二分,复杂度能做到O(n log m)。这些都是852/162的自然延续,建议学有余力的同学拿LeetCode 1901去练手。
5. 总结之外的实在话
这两道题放在整个二分专题里,地位有点像“台阶”:跳过去不难,但它让你真正理解二分为什么能处理看起来并不完全有序的数据。我对学生的建议一律是:先把852题的代码默写到滚瓜烂熟,再去做162题,你会发现自己连代码都不用改,提交就能通过。这种“变化题目条件但代码复用”的体验,比任何讲解都能增强信心。
我个人实际的重点提醒只有一个:永远别把二分模板当黑盒直接套,你要能说清楚每一行代码在保证什么。我见过太多人刷了50道二分题,碰到一道新题仍然栽在边界条件上,就是因为没有建立“区间排除”的思维框架,只是在背模板。把852和162学透、把为什么和mid+1比较、为什么left可以前进而right只用等mid这些问题想清楚,比刷题目数量重要得多。后续做其他二分题,你也会自然地开始思考“这道题要靠什么依据来排除一半区间”,而不是盯着题目发呆。