问题描述
峰值元素是指一个数值,它大于左右相邻的数值。在给定的整数数组中,数组可能包含多个峰值,要求返回任意一个峰值元素的索引。
问题要求:
- 假设
nums[-1] = nums[n] = -∞
,即数组边界以外的元素都为负无穷。 - 你必须实现时间复杂度为 O(log n) 的算法来解决此问题。
示例:
-
示例 1:
cpp输入:nums = [1, 2, 3, 1] 输出:2 解释:3 是峰值元素,返回索引 2。
-
示例 2:
cpp输入:nums = [1, 2, 1, 3, 5, 6, 4] 输出:1 或 5 解释:返回索引 1(峰值元素为 2),或者返回索引 5(峰值元素为 6)。
题目中的一些关键点:
- 边界外的假设
nums[-1] = nums[n] = -∞
是为了确保数组中最边界的元素也有可能是峰值。 - 数组中的峰值不止一个,题目要求返回任意一个。
- 由于峰值与局部相邻的值相关,我们不需要关注整个数组的全局结构,而可以利用局部信息高效解决问题。
方法一:线性遍历法(O(n))
cpp
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int n = nums.size();
// 特殊情况处理:数组长度为1,唯一的元素自然是峰值
if(n == 1) {
return 0;
}
// 特殊情况处理:数组长度为2,返回较大元素的索引
if (n == 2) {
if(nums[0] > nums[1]) {
return 0;
} else {
return 1;
}
}
// 遍历数组,从第二个元素到倒数第二个元素
for (int i = 1; i < n - 1; i++) {
// 检查当前元素是否大于左邻和右邻
if (nums[i] > nums[i - 1] && nums[i] > nums[i + 1]) {
return i; // 找到峰值,返回其索引
}
}
// 遍历结束后,未找到中间的峰值,需检查边界元素
// 检查最后一个元素是否大于倒数第二个元素
if (nums[n - 1] > nums[n - 2]) {
return n - 1; // 最后一个元素是峰值,返回其索引
}
// 如果最后一个元素不是峰值,则第一个元素必然是峰值
return 0;
}
};
详细条件覆盖解释:
-
数组长度为1的情况:
cppif(n == 1) { return 0; }
- 条件解释 :如果数组中只有一个元素,根据题目假设
nums[-1] = nums[n] = -∞
,该元素自然大于其左右相邻的"虚拟"元素-∞
。 - 返回值 :索引
0
。
- 条件解释 :如果数组中只有一个元素,根据题目假设
-
数组长度为2的情况:
cppif (n == 2) { if(nums[0] > nums[1]) { return 0; } else { return 1; } }
- 条件解释 :当数组长度为2时,比较两个元素的大小:
- 如果
nums[0] > nums[1]
,则nums[0]
是峰值。 - 否则,
nums[1]
是峰值。
- 如果
- 返回值 :较大元素的索引
0
或1
。
- 条件解释 :当数组长度为2时,比较两个元素的大小:
-
遍历中间元素:
cppfor (int i = 1; i < n - 1; i++) { if (nums[i] > nums[i - 1] && nums[i] > nums[i + 1]) { return i; } }
- 遍历范围 :从第二个元素 (
i = 1
) 到倒数第二个元素 (i = n - 2
)。 - 条件解释 :
nums[i] > nums[i - 1]
:当前元素大于左邻元素。nums[i] > nums[i + 1]
:当前元素大于右邻元素。- 两者同时满足时,
nums[i]
是一个峰值。
- 返回值 :找到的峰值元素的索引
i
。
- 遍历范围 :从第二个元素 (
-
检查最后一个元素:
cppif (nums[n - 1] > nums[n - 2]) { return n - 1; }
- 条件解释 :如果遍历过程中未找到中间的峰值,则需要检查数组的最后一个元素。
- 根据题目假设,
nums[n] = -∞
。 - 如果
nums[n - 1] > nums[n - 2]
,则nums[n - 1]
是峰值。
- 根据题目假设,
- 返回值 :最后一个元素的索引
n - 1
。
- 条件解释 :如果遍历过程中未找到中间的峰值,则需要检查数组的最后一个元素。
-
默认返回第一个元素:
cppreturn 0;
- 条件解释 :如果上述条件都不满足,说明第一个元素
nums[0]
是峰值。- 根据题目假设,
nums[-1] = -∞
。 - 因为前面的条件已经排除了数组中间和最后一个元素为峰值的可能性,所以第一个元素必然是峰值。
- 根据题目假设,
- 返回值 :第一个元素的索引
0
。
- 条件解释 :如果上述条件都不满足,说明第一个元素
代码覆盖的所有条件:
- 数组长度为1:直接返回唯一元素的索引。
- 数组长度为2:返回较大元素的索引。
- 中间元素的峰值:遍历数组中间部分,找到任意一个峰值。
- 最后一个元素的峰值:如果中间没有峰值,检查最后一个元素。
- 第一个元素的峰值:如果没有找到中间和最后一个元素为峰值的情况,则第一个元素为峰值。
时间复杂度分析:
- 最坏情况 :需要遍历整个数组,时间复杂度为 O(n)。
- 适用场景:对于较小或中等规模的数组,该方法效率较高。
- 局限性 :对于非常大的数组(如1,000,000个元素),线性遍历可能效率不够理想,需考虑更高效的算法(如二分查找,时间复杂度为 O(log n))。
总结:
通过详细分析和条件覆盖,确保了算法在各种情况下都能正确地找到一个峰值元素的索引。尽管线性遍历法简单直观,但在面对更大规模的数据时,可以考虑采用更高效的二分查找法以满足 O(log n) 的时间复杂度要求。
方法二:二分查找法(O(log n))
为了满足题目要求的 O(log n) 时间复杂度,我们可以采用二分查找(Binary Search)的方法。尽管二分查找通常用于有序数组,但在此问题中,我们利用数组的"局部有序性"来高效地找到峰值。
二分查找思路详解:
-
初始设置:
- 定义两个指针,
left
和right
,分别指向数组的起始和末尾。 - 通过不断缩小搜索范围来定位峰值。
- 定义两个指针,
-
中间元素比较:
- 计算中间位置
mid
。 - 比较
nums[mid]
与nums[mid + 1]
的值。
- 计算中间位置
-
确定搜索方向:
-
如果
nums[mid] > nums[mid + 1]
:- 说明峰值可能存在于左半部分,包括
mid
本身。 - 因为
nums[mid] > nums[mid + 1]
,根据题目定义,nums[mid]
可能是一个峰值,或者左侧存在更高的峰值。 - 更新右边界 :
right = mid
。
- 说明峰值可能存在于左半部分,包括
-
否则 (
nums[mid] < nums[mid + 1]
):- 说明峰值存在于右半部分,不包括
mid
本身。 - 因为
nums[mid] < nums[mid + 1]
,nums[mid + 1]
更大,可能是一个峰值,或者右侧存在更高的峰值。 - 更新左边界 :
left = mid + 1
。
- 说明峰值存在于右半部分,不包括
-
-
循环终止条件:
- 当
left == right
时,搜索范围缩小到一个元素,该元素即为峰值。 - 返回
left
(或right
,两者相等)作为峰值的索引。
- 当
二分查找代码实现:
cpp
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int left = 0;
int right = nums.size() - 1;
while (left < right) {
// 计算中间位置,防止溢出
int mid = left + (right - left) / 2;
// 比较中间元素与其右邻元素
if (nums[mid] > nums[mid + 1]) {
// 峰值在左侧(包括mid)
right = mid;
} else {
// 峰值在右侧(不包括mid)
left = mid + 1;
}
}
// 最终left == right,即为峰值的索引
return left;
}
};
代码详解与条件覆盖解释:
-
初始化:
cppint left = 0; int right = nums.size() - 1;
left
初始化为数组的起始索引0
。right
初始化为数组的末尾索引n - 1
(其中n
是数组长度)。- 这定义了我们当前搜索的范围为
[left, right]
。
-
计算中间位置
mid
:cppint mid = left + (right - left) / 2;
- 使用
left + (right - left) / 2
而非(left + right) / 2
以防止可能的整数溢出。
- 使用
-
比较
nums[mid]
与nums[mid + 1]
:cppif (nums[mid] > nums[mid + 1]) { right = mid; } else { left = mid + 1; }
-
如果
nums[mid] > nums[mid + 1]
:- 表明在
mid
位置的左侧(包括mid
本身)可能存在峰值。 - 具体原因如下:
- 由于
nums[mid] > nums[mid + 1]
,说明nums[mid]
相对于其右邻有上升趋势转为下降趋势,可能形成一个局部峰值。 - 即使
nums[mid]
本身不是峰值(因为可能nums[mid] > nums[mid + 1]
但nums[mid]
仍可能比nums[mid - 1]
小),我们可以确定至少在左半部分存在一个峰值。
- 由于
- 操作 :将
right
更新为mid
,缩小搜索范围至左半部分[left, mid]
。
- 表明在
-
否则 (
nums[mid] <= nums[mid + 1]
):- 表明在
mid
位置的右侧(不包括mid
本身)存在峰值。 - 具体原因如下:
- 由于
nums[mid] <= nums[mid + 1]
,说明数组在mid
位置有上升趋势,峰值必定在右侧。 - 无论
nums[mid + 1]
是否为峰值,至少可以排除[left, mid]
这一半部分,因为nums[mid + 1]
是比nums[mid]
大的。
- 由于
- 操作 :将
left
更新为mid + 1
,缩小搜索范围至右半部分[mid + 1, right]
。
- 表明在
-
-
循环结束与结果返回:
cppwhile (left < right) { // 循环体 } return left;
- 循环持续进行,直到
left == right
。 - 此时,
left
和right
都指向同一个元素,该元素必定是一个峰值。 - 返回
left
作为峰值的索引。
- 循环持续进行,直到
正确性证明:
-
存在性:
- 根据题目假设,
nums[-1] = nums[n] = -∞
。 - 因此,数组的首尾元素至少有一个是峰值,确保至少存在一个峰值。
- 根据题目假设,
-
单调性:
- 每次比较
nums[mid]
与nums[mid + 1]
,根据比较结果缩小搜索范围。 - 由于每次迭代都使得
left
和right
向着包含至少一个峰值的方向移动,算法不会遗漏任何可能的峰值。
- 每次比较
-
终止条件:
- 因为每次迭代都缩小了搜索范围,当
left == right
时,搜索范围缩小到一个元素,该元素必须是峰值。
- 因为每次迭代都缩小了搜索范围,当
示例分析:
示例 1:
输入 :nums = [1, 2, 3, 1]
-
初始状态:
left = 0
,right = 3
-
第一次迭代:
mid = 0 + (3 - 0) / 2 = 1
- 比较
nums[1] = 2
与nums[2] = 3
:2 < 3
,意味着峰值在右侧。- 更新
left = mid + 1 = 2
-
第二次迭代:
left = 2
,right = 3
mid = 2 + (3 - 2) / 2 = 2
- 比较
nums[2] = 3
与nums[3] = 1
:3 > 1
,意味着峰值在左侧(包括mid
)。- 更新
right = mid = 2
-
循环结束:
left = 2
,right = 2
- 返回
left = 2
,即峰值3
的索引。
示例 2:
输入 :nums = [1, 2, 1, 3, 5, 6, 4]
-
初始状态:
left = 0
,right = 6
-
第一次迭代:
mid = 0 + (6 - 0) / 2 = 3
- 比较
nums[3] = 3
与nums[4] = 5
:3 < 5
,意味着峰值在右侧。- 更新
left = mid + 1 = 4
-
第二次迭代:
left = 4
,right = 6
mid = 4 + (6 - 4) / 2 = 5
- 比较
nums[5] = 6
与nums[6] = 4
:6 > 4
,意味着峰值在左侧(包括mid
)。- 更新
right = mid = 5
-
第三次迭代:
left = 4
,right = 5
mid = 4 + (5 - 4) / 2 = 4
- 比较
nums[4] = 5
与nums[5] = 6
:5 < 6
,意味着峰值在右侧。- 更新
left = mid + 1 = 5
-
循环结束:
left = 5
,right = 5
- 返回
left = 5
,即峰值6
的索引。
二分查找方法的优势与局限:
-
优势:
- 时间效率高 :时间复杂度为 O(log n),适用于大规模数组。
- 空间效率高 :空间复杂度为 O(1),只需常数级别的额外空间。
-
局限:
- 不能找到所有峰值:该方法只返回一个峰值的索引,而不是所有峰值。
- 依赖局部信息:如果需要全局信息(如所有峰值),需要其他方法。
边界条件分析:
-
单元素数组:
- 例如
nums = [1]
。 left = 0
,right = 0
。- 直接返回
0
,因为唯一元素是峰值。
- 例如
-
两个元素数组:
- 例如
nums = [1, 2]
。 left = 0
,right = 1
.mid = 0
, 比较nums[0] = 1
与nums[1] = 2
:1 < 2
,更新left = 1
.
left = 1
,right = 1
。- 返回
1
,峰值为2
。
- 例如
-
全局递增或递减数组:
-
全局递增:
- 例如
nums = [1, 2, 3, 4, 5]
。 - 每次比较
nums[mid]
与nums[mid + 1]
时,始终nums[mid] < nums[mid + 1]
。 - 最终
left
会移动到最后一个元素,返回4
,峰值为5
。
- 例如
-
全局递减:
- 例如
nums = [5, 4, 3, 2, 1]
。 - 每次比较
nums[mid]
与nums[mid + 1]
时,始终nums[mid] > nums[mid + 1]
。 - 最终
right
会移动到第一个元素,返回0
,峰值为5
。
- 例如
-
-
多个峰值:
- 例如
nums = [1, 3, 2, 4, 1]
。 - 可能返回
1
或3
,都符合峰值定义。 - 二分查找会根据比较路径选择其中一个峰值。
- 例如
为什么二分查找方法总能找到一个峰值?
-
存在至少一个峰值:
- 题目假设
nums[-1] = nums[n] = -∞
,因此:- 如果数组首元素大于第二个元素,则首元素是峰值。
- 如果数组末元素大于倒数第二个元素,则末元素是峰值。
- 否则,至少存在一个中间峰值。
- 题目假设
-
二分查找的决策过程:
- 每次比较都确保至少有一个峰值在缩小后的搜索范围内。
- 如果
nums[mid] > nums[mid + 1]
,则[left, mid]
范围内至少有一个峰值。 - 如果
nums[mid] < nums[mid + 1]
,则[mid + 1, right]
范围内至少有一个峰值。 - 因此,搜索范围每次都包含至少一个峰值,最终必能找到一个峰值。
时间复杂度分析:
-
时间复杂度 :O(log n)
- 每次迭代将搜索范围缩小一半。
- 对于
n
个元素,最多进行log₂ n
次迭代。
-
空间复杂度 :O(1)
- 仅使用了常数级别的额外空间(变量
left
、right
、mid
)。
- 仅使用了常数级别的额外空间(变量
总结:
二分查找方法通过利用数组的局部有序性,巧妙地缩小搜索范围,从而在 O(log n) 的时间复杂度内找到一个峰值。每次比较 nums[mid]
与 nums[mid + 1]
,都能确保至少有一个峰值存在于更新后的搜索范围中,最终保证了算法的正确性和高效性。
相比于线性遍历法,二分查找法在处理大规模数组时表现尤为优越。然而,需要注意的是,该方法仅返回一个峰值的索引,若需要所有峰值,则需采用其他方法。