LeetCode 941. 有效的山脉数组
题目描述
题意分析
给一个整数数组
arr,判断它是不是「山脉数组」:存在一个下标i(0 < i < n - 1),使得arr[0] < arr[1] < ... < arr[i]且arr[i] > arr[i+1] > ... > arr[n-1]。是就返回true,否则false。定义里藏着三个必须同时满足的条件,缺一不可。第一,整个数组恰好由「一段严格上升」和「一段严格下降」拼成;第二,全程是严格不等号,任何位置出现相等都直接出局;第三,峰顶下标
i被限制在0 < i < n - 1,也就是上坡和下坡都必须真实存在,纯递增或纯递减都不算山脉。由此立刻可得n < 3必然返回false。约束里
n最大 $10^4$、元素值最大 $10^4$,规模小到几乎不构成限制,说明本题考的不是效率而是分类是否完备——它是一道边界题,不是算法题。既然只是判断形状,就没有必要排序、二分或者建任何辅助结构,一次线性扫描把形状「走」出来即可。特别注意元素值范围里允许出现重复值,而定义要求严格单调,所以像
[1,2,2,1]这种看起来很像山的输入是非法的。这类「看着像但不合法」的用例正是本题的主要陷阱。还有一个隐含的边界:题目没有说数组一定非空,
[]、[0]、[0,1]、[3,5,5]都可能出现,处理逻辑必须能自然覆盖它们,而不是靠零散的特判打补丁。
解法:双指针上坡/下坡扫描
核心思路
暴力的想法是:先枚举峰顶下标
i,再分别验证[0, i]严格递增、[i, n-1]严格递减。这在 $O(n^2)$ 下能过,但白白做了重复功——因为峰顶其实不需要枚举,它是被数据唯一确定的。瓶颈在于「峰顶未知」这个假设本身是多余的。观察:如果从下标 0 出发,只要
arr[i] < arr[i+1]成立就一直往右走,那么停下来的位置就是第一个不再上升的位置。而山脉数组的形状保证了这个位置只可能是峰顶——因为在峰顶之前每一步都严格上升,走到峰顶后arr[i] > arr[i+1]立刻中止。于是峰顶可以在 $O(n)$ 内一次性定位,不需要枚举。记指针为
i。第一阶段的不变量是:循环每退出一次迭代,都保证arr[0] < arr[1] < ... < arr[i]严格成立;循环终止时,要么i == n - 1(走到了末尾),要么arr[i] >= arr[i+1](遇到了平台或下坡)。第二阶段从同一个
i继续,只要arr[i] > arr[i+1]就继续走,不变量是:arr[peak] > ... > arr[i]严格成立。终止时要么走到末尾,要么遇到平台或上坡。现在把三个条件逐一映射到这两阶段的终止状态上:
- 上坡必须非空 ⇔ 第一阶段结束时
i != 0;- 下坡必须非空 ⇔ 第一阶段结束时
i != n - 1(否则从头升到尾,没有下坡);- 中间不能有平台或二次上升 ⇔ 第二阶段结束时恰好
i == n - 1。这三个检查合在一起既充分又必要:严格性由两个循环条件里的
<和>保证(相等时循环不会前进),完整性由最终i == n - 1保证(任何提前停下都说明中间出现了非法形状)。因此只需两趟扫描加三次判断,不需要任何额外分类。
解题步骤
- 先挡掉
n < 3:山脉至少要「升一步 + 降一步」,最少三个元素。为什么要显式写:后面要访问arr[0]和arr[n-1]并做i == n - 1比较,先挡掉小数组能让后续逻辑不必再操心长度。- 上坡阶段:
i = 0,当i + 1 < n && arr[i] < arr[i+1]时i++。为什么循环条件带i + 1 < n:arr[i+1]的访问必须在合法范围内,否则走到末尾会越界。为什么用严格<:遇到相等时循环立刻停住,把「平台」交给后面的检查判死,这正是严格单调要求的落点。- 检查峰顶合法:若
i == 0返回false,说明一开始就没上升(首元素不小于次元素),没有左坡;若i == n - 1返回false,说明一路升到底,没有右坡。为什么两个都要查:它们分别对应定义里i > 0和i < n - 1两个约束,漏掉任何一个都会把纯递增或纯递减的数组误判为山脉。- 下坡阶段:继续用同一个
i,当i + 1 < n && arr[i] > arr[i+1]时i++。为什么复用i而不是新开指针:第一阶段已经把i停在峰顶,从峰顶继续往下走天然衔接,也省掉一个变量。- 最终判定:返回
i == n - 1。为什么这一条就够:若中途遇到相等或再次上升,第二阶段会提前停住,i到不了末尾;能走到末尾就说明整段下坡严格成立,配合前两个检查,三个条件全部满足。以
arr = [0, 3, 2, 1]走一遍。n = 4,通过长度检查。
上坡阶段:i = 0,arr[0]=0 < arr[1]=3,i变 1;arr[1]=3 < arr[2]=2不成立,停在i = 1。
峰顶检查:i既不是 0 也不是n - 1 = 3,通过,峰顶就是下标 1、值 3。
下坡阶段:arr[1]=3 > arr[2]=2,i变 2;arr[2]=2 > arr[3]=1,i变 3;此时i + 1 = 4不小于n,循环结束。
最终判定:i == 3 == n - 1,返回true。再看反例
arr = [3, 5, 5]。长度过关;上坡阶段3 < 5让i走到 1,再比arr[1]=5 < arr[2]=5不成立而停住;峰顶检查通过(i = 1,非首非尾);下坡阶段arr[1]=5 > arr[2]=5不成立,i原地不动;最终i = 1 != 2,返回false——平台正是在这一步被判死的。
代码实现
class Solution {
public boolean validMountainArray(int[] arr) {
int n = arr.length;
if (n < 3) {
return false;
}
int i = 0;
// 严格上升才前进,遇到相等或下降就停在峰顶候选位。
while (i + 1 < n && arr[i] < arr[i + 1]) {
i++;
}
// 峰顶不能落在两端,否则缺上坡或缺下坡。
if (i == 0 || i == n - 1) {
return false;
}
// 从峰顶继续严格下降。
while (i + 1 < n && arr[i] > arr[i + 1]) {
i++;
}
// 只有恰好走到末尾,才说明中间没有平台或二次上升。
return i == n - 1;
}
}
func validMountainArray(arr []int) bool {
n := len(arr)
if n < 3 {
return false
}
i := 0
// 严格上升才前进,遇到相等或下降就停在峰顶候选位。
for i+1 < n && arr[i] < arr[i+1] {
i++
}
// 峰顶不能落在两端,否则缺上坡或缺下坡。
if i == 0 || i == n-1 {
return false
}
// 从峰顶继续严格下降。
for i+1 < n && arr[i] > arr[i+1] {
i++
}
// 只有恰好走到末尾,才说明中间没有平台或二次上升。
return i == n-1
}
复杂度分析
- 时间复杂度:$O(n)$。凭什么:指针
i只会单向递增、从不回退,两个循环合起来最多把i从 0 推到n - 1,每次推进只做一次比较,因此总操作数与n同阶。- 空间复杂度:$O(1)$。凭什么:只用了
n和i两个整型变量,没有复制数组也没有开辅助结构,占用与输入规模无关。
关键点总结
- 「严格单调」在代码里的唯一落点就是比较符不带等号:用
<和>会让指针在相等处自动卡住,把平台交给后续检查判死,这比显式写if (arr[i] == arr[i+1]) return false更简洁也更不易漏。- 用「走到哪里停下」代替「枚举分割点」,是单调形状判定题的通用降维手法:只要形状唯一确定,分割点就不必枚举。
- 循环条件里的
i + 1 < n与循环体里的arr[i+1]必须成对出现,凡是访问相邻元素的扫描都要先确认右边界。- 把题目定义的每个约束显式对应到代码中的一处检查(左坡非空 →
i != 0,右坡非空 →i != n-1,无平台 → 末尾判定),是边界题不漏条件的可靠做法。- 面试视角:这道题面试官不看你写得多快,而是看你能否主动报出边界用例。写完后直接口述
[]、[0,1]、[3,5,5]、[0,1,2,3]、[3,2,1,0]、[2,1]六组用例并说明各自走到哪一步被拒,比多写一个解法更有说服力。- 同样的两阶段扫描骨架可以直接改造成「求最长山脉」「统计山脉个数」,区别只在于把布尔判定换成计数与最值维护。
易错点总结
- 错误写法:上坡循环用
arr[i] <= arr[i+1]→ 用例[3,5,5,1]会越过平台把峰顶定到下标 2,随后下坡成立返回true,但正确答案是false。- 错误写法:下坡循环用
arr[i] >= arr[i+1]→ 用例[1,3,2,2]会一路走到末尾返回true,但末尾存在平台,正确答案是false。- 错误写法:漏掉
i == 0的检查 → 用例[3,2,1]里上坡一步没走,i停在 0,下坡直接走到末尾,返回true,而纯递减不是山脉。- 错误写法:漏掉
i == n - 1的检查 → 用例[0,1,2,3]里一路升到尾,第二个循环不执行,最终i == n - 1判定为true,而纯递增不是山脉。- 错误写法:漏掉
n < 3的前置判断 → 用例[]在 Java 里n - 1 = -1,i == 0恰好成立侥幸返回false,但用例[1]时i == 0 == n - 1也返回false属于蒙对;一旦有人把两个检查改写成访问arr[n-1],空数组立刻越界。- 错误写法:循环条件写成
i < n而在体内访问arr[i+1]→ 用例[0,1,2]在i = 2时读arr[3],Java 抛数组越界,Go 直接 panic。- 错误写法:下坡阶段另起一个从
n - 1向左走的指针,最后判断两指针是否相遇 → 用例[1,2,2,1]中左指针停在 1、右指针停在 2,二者不等确实返回false,但用例[0,2,2,2,0]里左停 1 右停 3,若代码写成「相遇或相邻即通过」就会误判为true。- 错误写法:先判断峰顶再判断长度,顺序颠倒 → 用例
[]时先执行i == n - 1与n = 0的比较,逻辑虽不崩但语义混乱,一旦后续改动引入arr[i]访问就会崩。- 错误写法:把返回值写成
i >= n - 1→ 用例上没有区别,但一旦第二个循环因边界写错让i越过n - 1,这个宽松判定会掩盖越界 bug,使非法输入被判为true。- 错误写法:用「先找最大值下标当峰顶」代替扫描 → 用例
[1,3,3,1]里最大值有两个下标,取到哪个都无法反映平台非法,需要额外判重复才能补救。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 852. 山脉数组的峰顶索引 | 中等 | 已保证是山脉,只求峰顶下标,可用二分把 $O(n)$ 压到 $O(\log n)$ |
| 845. 数组中的最长山脉 | 中等 | 从「整体是否为山」变成「找最长子数组山」,需枚举峰顶双向扩展 |
| 1095. 山脉数组中查找目标值 | 困难 | 三次二分:先找峰顶,再在升段与降段分别二分,且访问次数受限 |
| 162. 寻找峰值 | 中等 | 只保证相邻不等而非全局山形,二分靠的是「往高处走必有峰」 |
| 1671. 得到山形数组的最少删除次数 | 困难 | 转成双向最长上升子序列,考的是 LIS 而不是扫描 |