☰
无序数组也能二分?LeetCode 162寻找峰值全解
2026/9/28 14:41:14 网站建设 项目流程

开头

看到“寻找峰值”这道题,第一反应大多是这样的:数组无序,要找峰值,先遍历一遍,每个位置和左右比一比,O(n)搞定,收工。然后看到题目要求的时间复杂度是 O(log n),很多人会愣一下——无序数组怎么二分?这题是不是出错了?

没有出错。162这道题是二分查找里非常经典的一道“反直觉”题目,它在LeetCode上的地位不亚于“二分查找”本身。很多刷题攻略里把它列为入门进阶的必做题目,不是因为代码有多难,而是因为“无序数组也能二分”这个认知一旦打通,后面做旋转数组、山脉数组、寻找两个有序数组的中位数这类题目,思路会顺很多。

这道题的核心定义很简单:nums[i] 比它左右两个邻居都大,就是峰值。题目还特意说了一句,边界位置只看一侧,nums[-1] 和 nums[n] 都当作负无穷来对待。最终要求返回任意一个峰值下标即可。

本文会从峰值定义的直觉讲起,一步步推演为什么无序数组可以用二分,给出两种常见的二分实现,再把边界用例和容易翻车的细节全部拆开讲一遍,最后聊聊它的变种题和面试里真正会被追问的点。无论你是刚开始刷题的新手,还是准备面试想查漏补缺的老手,这篇都能给你一些不一样的视角。

1. 为什么无序数组也能二分:峰值问题背后的爬坡逻辑

1.1 峰值定义拆解:从“局部最大”说起

先回到最朴素的定义。峰值就是“局部最大”——一个元素比左边大,同时比右边大。注意,这里的“局部”很关键,它不要求这个元素是全局最大,只要在它周围那一小片区域里称王就行。

这就像一个山脊上的多个山头。整条山脉有最高峰,但沿途会有很多小山峰,每个小山峰只要比它脚下两侧的地势高,就算一个峰。题目要求的是“随便找一个山头”,不是“找最高的那座山”。

数组末尾那个负无穷的约定也很有意思。它抹平了边界和中间位置的差异,让每个位置都能统一地用“比左侧大且比右侧大”来判断。如果数组是 [1, 2, 3],那下标 2 就是峰值,因为 nums[2] = 3 大于左侧的 2,右侧当成负无穷,自然成立。

这个定义看起来平淡无奇,但它是后续所有推理的地基。峰值不是“全局特殊位置”,而是“局部特殊位置”,正是因为局部,才给二分提供了可操作的空间。

1.2 O(log n) 是出题人给出的最强提示

刷题时候,很多人忽略了一个重要信号:题目明确要求 O(log n),而数组也不是某种有序结构。在算法题里,看到 O(log n) 的要求,九成情况就是在告诉你——用二分。

但这里的二分和传统二分有个认知冲突。传统二分的前提是数组有序,我们可以根据 mid 的值和目标值的大小关系,确定目标到底在左半边还是右半边。这个逻辑靠的是“单调性”。现在数组无序,你凭什么砍掉一半?砍掉的那一半里万一藏着峰值怎么办?

这就是162这道题的精髓。它用的是一个更弱的条件——不是全局有序,而是“任意两个相邻元素不相等”。这个条件足够让我们做出一个关键的局部判断:只要看 mid 和 mid+1 的大小关系,就能决定下一步往哪边走,且不会漏掉峰值。

1.3 爬坡论证:任意起点向上走一定能遇到峰值

想理解这个二分为什么成立,最直观的模型是“爬山”。你站在数组的任意一个位置,往左边看一眼,往右边看一眼。如果两边的值都比当前矮,那你脚下就是峰值,停下来即可。如果有一边更高,那你就往高的那边走一步。

这里有个看起来平凡但非常重要的结论:只要不断往更高的方向走,最终一定能到达一个峰值。为什么?因为你每一步都在走向一个比当前位置更高的位置,而数组是有边界的。在一个有限区间里,高度不可能一直增加下去。走到边界的时候,边界外侧被定义成负无穷,边界本身必然比外侧高,所以边界位置也是一个合法的峰值。

换句话说,从数组的任意起点出发,沿着“更高的方向”走,最终一定会停在一个峰顶上。这条性质不依赖于数组的整体有序性,只依赖于两个事实:数组有限,且边界外侧是负无穷。

二分的逻辑正是基于这个爬坡性质。每次我们看 mid 位置的趋势,如果 nums[mid] < nums[mid + 1],说明在 mid 右侧有一个向上的坡,那么沿着这个坡走,最终一定能碰到某个峰值,所以峰值一定存在于 mid 的右侧区域。反之,如果 nums[mid] > nums[mid + 1],说明 mid 自身或者 mid 的左侧存在峰值,保留左半边继续找。

这就是整个二分方案的灵魂:二分不是靠“哪里有序”,而是靠“哪里一定存在峰值”来判断。

2. 标准二分推演:两个版本的代码与每一步的依据

2.1 闭区间写法:l=0, r=n-1,nums[mid] 与 nums[mid+1] 比较

最常见的写法是维护一个闭区间 [l, r],初始 l=0,r=n-1。每次取中点 mid = (l + r) // 2,然后比较 nums[mid] 和 nums[mid + 1]。

这里有个关键点需要先明确:mid + 1 会不会越界?在 while l < r 的循环条件下,mid 永远小于 r,所以 mid + 1 最大也就是 r,不会超出数组范围。这是这个写法能成立的前提。

比较的结果只有两种情况:

  • nums[mid] < nums[mid + 1]:说明右邻居更高,右侧存在上坡,峰值在 [mid + 1, r] 区间内,令 l = mid + 1。
  • nums[mid] > nums[mid + 1]:说明当前位置比右侧高,峰值可能在 mid 本身,也可能在 mid 左侧,令 r = mid。

注意第二种情况里,r = mid 而不是 r = mid - 1。这是最容易搞错的地方。因为 nums[mid] 本身可能就是峰值,如果直接把 mid 排除掉,可能会丢答案。保留 mid,才能保证算法正确性。

循环结束时,l == r,这个位置就是一个峰值下标。这个写法的好处是直观、好记,是大多数题解采用的版本。

class Solution: def findPeakElement(self, nums: List[int]) -> int: 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

C++版本几乎一模一样:

class Solution { public: int findPeakElement(vector<int>& nums) { int left = 0, right = nums.size() - 1; while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] < nums[mid + 1]) { left = mid + 1; } else { right = mid; } } return left; } };

两种代码的复杂度都是 O(log n) 时间,O(1) 空间。

2.2 为什么比较 mid 和 mid+1,而不是 mid-1 和 mid+1

很多初学者会想,判断峰值不是应该同时和左右两边比较吗?为什么二分里只比较 nums[mid] 和 nums[mid + 1]?

这是一个很值得想清楚的问题。如果同时比较 nums[mid - 1] 和 nums[mid + 1],会出现几种情况:mid 比左边小但比右边大,或比左边大但比右边小,这时候你很难决定往哪边走。更重要的是,比较 mid-1 还要处理 mid=0 的边界越界问题,代码会变复杂。

只比较 nums[mid] 和 nums[mid + 1],本质上是把问题简化成一个“方向判断”。我只需要知道右侧是上坡还是下坡。右侧上坡,答案一定在右边;右侧下坡,答案一定在左边(包括 mid 自己)。这个判断只依赖一个相邻关系,既不需要担心边界,又能保证区间收缩的正确性。

这有点像走迷宫时只判断前方是上坡还是下坡,而不需要同时判断左右两个方向。每走一步只需要看一个方向,方向对了,最终就能走出去。

2.3 循环不变量:为什么这个二分不会死循环

二分最容易出的问题就是死循环,尤其是 r = mid 这种写法配合 mid = (l + r) // 2 的向下取整。

我们来验证一下。假设某一轮 l 和 r 已经相邻了,即 r = l + 1。这时 mid = (l + r) // 2 = l。

  • 如果 nums[l] < nums[r],走 left = mid + 1 = l + 1 = r,循环结束。
  • 如果 nums[l] > nums[r],走 right = mid = l,此时 l == r,循环结束。

两种情况下循环都会终止,因为每次迭代区间长度都在减小(要么 left 变大,要么 right 变小)。区间长度从 n 一路缩到 1,迭代次数正好是 log2(n) 级别。

顺便提一个细节:mid 的计算用 mid = left + (right - left) // 2,在 C++/Java 里可以防止 left + right 整型溢出。虽然 LeetCode 的测试数据不太会触发溢出,但面试官有时候会问,写成这种形式更稳妥。

2.4 从爬坡视角看这个二分为什么不会漏答案

我有一次在讨论区看到一个提问:“如果 mid 正好在一个低谷,两边都是上坡,这时候你说右边一定有峰值,所以去右边,这个结论对吗?”

这个问题问得很好。低谷位置 nums[mid] < nums[mid + 1],其实就是说右侧有个向上的趋势。从数学上说,从这个位置开始向右,只要一路沿着上升方向走,一定会遇到一个转折点或走到边界,那个点就是峰值。这个结论不依赖于 mid 左边是什么样,因为我们已经不看左边了。

而如果 nums[mid] > nums[mid + 1],情况稍微复杂一些:mid 自己可能是峰值,也可能左侧有峰值,但右侧不一定有峰值(比如数组右半段一路下降到底)。所以这时候必须把 mid 保留在搜索区间里,去左边找。这个“保大保左”的策略,本质上就是在维护一个不变量:峰值始终在 [l, r] 区间内部。

这个不变量是整个二分正确性的核心。每次迭代后,峰值的候选区间都包含当前的 l 和 r,直到区间收敛到单点,那个点必然是峰值。

3. 边界条件与特殊用例:容易翻车的几个角落

3.1 长度为 1 的数组

这是最极端的边界:nums = [2]。按照定义,左右两侧都是负无穷,所以下标 0 本身就是一个峰值。代码里 left=0, right=0,while 循环根本不进入,直接返回 0。正确。

很多人在这个用例上焦虑,觉得是不是应该特判一下。其实不必,代码天然处理了这个情况。这也是闭区间写法的好处,初始区间就是正确答案时,循环不打扰你。

3.2 单调递增和单调递减数组

单调递增数组,比如 [1, 2, 3, 4, 5],峰值在下标 4。代码走的过程是:mid=2,nums[2]=3 < nums[3]=4,走右侧;mid=3,nums[3]=4 < nums[4]=5,走右侧;left 变成 4,循环结束,返回 4。正确。

单调递减数组,比如 [5, 4, 3, 2, 1],峰值在下标 0。代码走的过程是:mid=2,nums[2]=3 > nums[3]=2,走左侧 r=2;mid=0,nums[0]=5 > nums[1]=4,走左侧 r=0;循环结束,返回 0。正确。

这两个用例测试的是代码对“上坡/下坡”方向的反应,是验证二分正确性的基础用例。

3.3 nums[mid] 恰好是端点位置

当 mid == 0 时,比较 nums[0] 和 nums[1]。如果 nums[0] > nums[1],说明下标 0 本身就是一个峰值(左边界视为负无穷),走 r = 0,循环结束。这个逻辑是完备的,因为左边界外侧被定义成负无穷,不需要真实比较。

当 mid == r 的情况不会发生,因为 mid = (l + r) // 2 且 l < r 时,mid 最大只能是 r - 1。这也是为什么能安全比较 nums[mid + 1] 的原因。

3.4 多个峰值时返回哪一个

题目要求“返回任意一个峰值下标即可”,这给了算法很大的自由。比如数组 [1, 3, 5, 4, 2, 6, 1],峰值为下标 2(值5)和下标 5(值6)。二分可能返回任何一个,取决于 mid 的落点。

这个特性是很多人忽略的。如果你自己测试时发现返回值不是预期的那个峰值,先别急着说代码错了,检查一下是否题目允许任意峰值。LeetCode 的判定逻辑是:只要返回的下标对应元素确实是峰值,就算通过。

3.5 相同元素相邻的坑

题目明确说了 nums[i] != nums[i + 1],所有相邻元素不相等。这个条件保证了比较 nums[mid] 和 nums[mid + 1] 时不会出现相等的情况。

如果没有这个约束,比如数组 [1, 2, 2, 1],在平地上二分就会彻底失效——因为无法判断该往哪边走。面试时如果能主动说出“这个解法依赖相邻元素不相等的条件”,是一个很好的加分点,说明你理解了算法成立的前提。

3.6 这些边界用例的测试矩阵

用例数组期望输出说明
单元素[3]0边界的负无穷约定生效
单调递增[1, 2, 3]2峰值在右边界
单调递减[3, 2, 1]0峰值在左边界
多个峰值[1, 3, 5, 4, 2]2返回任意合法峰值
先升后降[1, 2, 3, 1]2标准单峰形态
对称谷底[5, 4, 3, 4, 5]0 或 4两个峰值都在边界

4. 从162到852:变种题与二分查找家族图谱

4.1 852山脉数组的峰值索引

LeetCode 852题(山脉数组的峰顶索引)和162非常像。山脉数组的定义是:前半段严格递增,后半段严格递减,整个数组只有一个峰值。题目要求找到那个唯一峰值。

162和852的区别在于:162的数组可能有多个峰值,而且没有全局的增减规律;852的数组只有一个峰值,增减规律全局成立。

但两者的二分代码几乎可以一样。852用 num[mid] < num[mid + 1] 判断是否在爬坡段,是就走右侧,否则走左侧。这就是162二分逻辑在“全局单峰”情况下的特化。

如果先做852再做162,会觉得162很顺;反过来先做162再做852,会觉得852简直太简单了。两者的关系是包含与被包含的关系。

4.2 153寻找旋转数组最小值:同一个判断逻辑的不同表达

再往外扩展一点,153题(寻找旋转排序数组中的最小值)也用二分,但判断条件变成了 nums[mid] 和 nums[right] 的大小关系。如果 nums[mid] > nums[right],说明最小值在右半边;否则在左半边(包括 mid)。

和162对比可以发现,这类二分的共同点都是:不需要整个数组有序,只需要一个局部的“比较规则”能告诉我们答案在哪一侧。162的规则是“右侧上坡就在右边”,153的规则是“右端无序的最小值在右边”。

这类题目做多了会形成一个印象:二分查找的本质不是“有序数组查找”,而是“能通过局部信息排除掉一半搜索空间的问题求解”。有序数组只是这个条件的一个特例。

4.3 852与162的本质区别:全局有序 vs 局部可判断

我把两者的区别列个表,这样更直观:

维度162 寻找峰值852 山脉数组峰值
峰值数量至少1个,可能多个恰好1个
数组形态任意无序,相邻不等严格递增后严格递减
比较对象nums[mid] vs nums[mid+1]同样比较 nums[mid] vs nums[mid+1]
返回要求任意一个峰值唯一峰值
难度中等简单

代码层面最核心的区别其实是:162需要证明“为什么随便往一个上坡方向走就能找到峰值”,而852因为这个证明变得显而易见——单峰结构决定了只有一个坡,顺着坡走必然到顶。

4.4 判定条件的统一视角:什么时候能用二分

总结一下,一个题目能用二分解的核心条件有三个:

一是存在一个可判断的“方向规则”,使得任意位置都能确定答案在左还是右。二是排除掉的那一半区域不可能含有答案,或者含有答案但不影响最终结果(比如162只要任意峰值)。三是搜索区间能不断缩小,最终收敛到答案。

很多二分题目的难点不在代码,而在找到那个“方向规则”。162提供了一个很好的训练素材:它让你看到,无序数组也能二分,只要你能证明“被排除的部分不会影响答案”就行。

5. 刷题多年回头看:这些细节面试官更在乎

5.1 先说边界再写代码,是区分新手和老手的标志

我见过很多人在白板上写这道题,写之前信誓旦旦,写完一跑就挂在边界用例上。比较典型的错误是:写了 nums[mid - 1] 来判断,结果 mid = 0 时直接越界;或者写了 r = mid - 1,把峰值可能的位置直接丢掉了。

有经验的做法是:动笔之前,先把几个边界用例在脑子里过一遍——长度1的数组、单调递增、单调递减、多峰值数组。然后明确说明“我用闭区间 [l, r],循环条件是 l < r,mid 取左中点,所以不会越界”。这一句话就能让面试官觉得你这个人是真的理解二分,而不是背模板。

我自己的习惯是,在白板上先写出“循环不变量”那一行注释:搜索区间内一定包含至少一个峰值。然后再写代码。这样就算中间写错,回头检查也有一个参考基准。

5.2 手写二分容易出错的三个点

第一个点是 mid 的计算位置。用 (l + r) // 2 在绝大多数场景都没问题,但在 l + r 可能溢出的语言里(C++、Java),应该写成 l + (r - l) // 2。面试官喜欢问这个。

第二个点是区间收缩逻辑。nums[mid] > nums[mid + 1] 时,为什么是 r = mid 而不是 r = mid - 1?因为 mid 可能就是峰值,把它排除掉就错了。这个点是这道题最容易写错的地方,没有之一。

第三个点是循环条件的等号问题。如果写 while l <= r,配合 r = mid 这种更新方式会死循环。因为当 l == r 时,mid == l == r,如果走 r = mid,区间不变,无限循环。所以这类二分统一用 l < r,结束条件是 l == r,答案就是那个位置。

5.3 复杂度会不会被追问:为什么一定是 O(log n)

二分的时间复杂度证明比较直接:每一轮迭代,搜索区间长度至少减半(因为 l = mid + 1 或 r = mid,mid 是左中点,区间长度从 len 减到最多 len/2),所以要 log2(n) 轮。空间复杂度是 O(1),因为只用了几个变量。

但如果面试官继续追问“为什么能保证减半后还有峰值”,这就回到第一部分的爬坡论证了。我建议准备这道题时把这个几何直觉想透,因为面试官很喜欢让人“解释一下为什么二分对无序数组有效”。

5.4 一个很少人提但面试好用的验证技巧

刷完这道题之后,我习惯做一件事:写一个暴力验证函数,随机生成多组数据,对比二分结果和暴力结果是否一致。这不是为了提交,而是为了给自己建立信心——尤其是当你手写二分的边界条件拿不准时,用随机数据暴力验证是验证正确性最快的方式。

import random def brute_force(nums): n = len(nums) for i in range(n): left_val = nums[i - 1] if i > 0 else float('-inf') right_val = nums[i + 1] if i < n - 1 else float('-inf') if nums[i] > left_val and nums[i] > right_val: return i return -1 def verify(): for _ in range(10000): nums = [random.randint(1, 50) for _ in range(random.randint(1, 30))] # 保证相邻不等 for i in range(1, len(nums)): if nums[i] == nums[i - 1]: nums[i] += 1 sol = Solution() idx = sol.findPeakElement(nums) left_val = nums[idx - 1] if idx > 0 else float('-inf') right_val = nums[idx + 1] if idx < len(nums) - 1 else float('-inf') assert nums[idx] > left_val and nums[idx] > right_val, (nums, idx) print("all passed")

这个验证脚本看起来不起眼,但能发现绝大多数隐藏的边界问题。我建议你也跑一下,尤其是把数组长度压到1和2多跑几次。

做这道题的实际体会是:第一次看题解觉得“不过如此”,真正自己写、自己画图、自己推边界之后才发现,主要是卡在爬坡直觉没建立起来。“无序数组里的二分”这个认知一旦建立,后面再遇到旋转数组、峰值集合、局部极值这类题目,思考路径就会清晰非常多。这个爬坡直觉值得花时间真正想通,而不是背下代码了事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询