掌握寻找峰值元素的高效算法:从线性遍历到二分查找

问题描述

峰值元素是指一个数值,它大于左右相邻的数值。在给定的整数数组中,数组可能包含多个峰值,要求返回任意一个峰值元素的索引。

题目链接:162. 寻找峰值 - 力扣(LeetCode)

问题要求:

  1. 假设 nums[-1] = nums[n] = -∞,即数组边界以外的元素都为负无穷。
  2. 你必须实现时间复杂度为 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. 数组长度为1的情况

    cpp 复制代码
    if(n == 1) {
        return 0;
    }
    • 条件解释 :如果数组中只有一个元素,根据题目假设 nums[-1] = nums[n] = -∞,该元素自然大于其左右相邻的"虚拟"元素 -∞
    • 返回值 :索引 0
  2. 数组长度为2的情况

    cpp 复制代码
    if (n == 2) {
        if(nums[0] > nums[1]) {
            return 0;
        } else {
            return 1;
        }
    }
    • 条件解释 :当数组长度为2时,比较两个元素的大小:
      • 如果 nums[0] > nums[1],则 nums[0] 是峰值。
      • 否则,nums[1] 是峰值。
    • 返回值 :较大元素的索引 01
  3. 遍历中间元素

    cpp 复制代码
    for (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
  4. 检查最后一个元素

    cpp 复制代码
    if (nums[n - 1] > nums[n - 2]) {
        return n - 1;
    }
    • 条件解释 :如果遍历过程中未找到中间的峰值,则需要检查数组的最后一个元素。
      • 根据题目假设,nums[n] = -∞
      • 如果 nums[n - 1] > nums[n - 2],则 nums[n - 1] 是峰值。
    • 返回值 :最后一个元素的索引 n - 1
  5. 默认返回第一个元素

    cpp 复制代码
    return 0;
    • 条件解释 :如果上述条件都不满足,说明第一个元素 nums[0] 是峰值。
      • 根据题目假设,nums[-1] = -∞
      • 因为前面的条件已经排除了数组中间和最后一个元素为峰值的可能性,所以第一个元素必然是峰值。
    • 返回值 :第一个元素的索引 0
代码覆盖的所有条件:
  1. 数组长度为1:直接返回唯一元素的索引。
  2. 数组长度为2:返回较大元素的索引。
  3. 中间元素的峰值:遍历数组中间部分,找到任意一个峰值。
  4. 最后一个元素的峰值:如果中间没有峰值,检查最后一个元素。
  5. 第一个元素的峰值:如果没有找到中间和最后一个元素为峰值的情况,则第一个元素为峰值。
时间复杂度分析:
  • 最坏情况 :需要遍历整个数组,时间复杂度为 O(n)
  • 适用场景:对于较小或中等规模的数组,该方法效率较高。
  • 局限性 :对于非常大的数组(如1,000,000个元素),线性遍历可能效率不够理想,需考虑更高效的算法(如二分查找,时间复杂度为 O(log n))。
总结:

通过详细分析和条件覆盖,确保了算法在各种情况下都能正确地找到一个峰值元素的索引。尽管线性遍历法简单直观,但在面对更大规模的数据时,可以考虑采用更高效的二分查找法以满足 O(log n) 的时间复杂度要求。


方法二:二分查找法(O(log n))

为了满足题目要求的 O(log n) 时间复杂度,我们可以采用二分查找(Binary Search)的方法。尽管二分查找通常用于有序数组,但在此问题中,我们利用数组的"局部有序性"来高效地找到峰值。

二分查找思路详解:
  1. 初始设置

    • 定义两个指针,leftright,分别指向数组的起始和末尾。
    • 通过不断缩小搜索范围来定位峰值。
  2. 中间元素比较

    • 计算中间位置 mid
    • 比较 nums[mid]nums[mid + 1] 的值。
  3. 确定搜索方向

    • 如果 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
  4. 循环终止条件

    • 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;
    }
};
代码详解与条件覆盖解释:
  1. 初始化

    cpp 复制代码
    int left = 0;
    int right = nums.size() - 1;
    • left 初始化为数组的起始索引 0
    • right 初始化为数组的末尾索引 n - 1(其中 n 是数组长度)。
    • 这定义了我们当前搜索的范围为 [left, right]
  2. 计算中间位置 mid

    cpp 复制代码
    int mid = left + (right - left) / 2;
    • 使用 left + (right - left) / 2 而非 (left + right) / 2 以防止可能的整数溢出。
  3. 比较 nums[mid]nums[mid + 1]

    cpp 复制代码
    if (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]
  4. 循环结束与结果返回

    cpp 复制代码
    while (left < right) {
        // 循环体
    }
    return left;
    • 循环持续进行,直到 left == right
    • 此时,leftright 都指向同一个元素,该元素必定是一个峰值。
    • 返回 left 作为峰值的索引。
正确性证明:
  • 存在性

    • 根据题目假设,nums[-1] = nums[n] = -∞
    • 因此,数组的首尾元素至少有一个是峰值,确保至少存在一个峰值。
  • 单调性

    • 每次比较 nums[mid]nums[mid + 1],根据比较结果缩小搜索范围。
    • 由于每次迭代都使得 leftright 向着包含至少一个峰值的方向移动,算法不会遗漏任何可能的峰值。
  • 终止条件

    • 因为每次迭代都缩小了搜索范围,当 left == right 时,搜索范围缩小到一个元素,该元素必须是峰值。
示例分析:
示例 1:

输入nums = [1, 2, 3, 1]

  • 初始状态

    • left = 0, right = 3
  • 第一次迭代

    • mid = 0 + (3 - 0) / 2 = 1
    • 比较 nums[1] = 2nums[2] = 3
      • 2 < 3,意味着峰值在右侧。
      • 更新 left = mid + 1 = 2
  • 第二次迭代

    • left = 2, right = 3
    • mid = 2 + (3 - 2) / 2 = 2
    • 比较 nums[2] = 3nums[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] = 3nums[4] = 5
      • 3 < 5,意味着峰值在右侧。
      • 更新 left = mid + 1 = 4
  • 第二次迭代

    • left = 4, right = 6
    • mid = 4 + (6 - 4) / 2 = 5
    • 比较 nums[5] = 6nums[6] = 4
      • 6 > 4,意味着峰值在左侧(包括 mid)。
      • 更新 right = mid = 5
  • 第三次迭代

    • left = 4, right = 5
    • mid = 4 + (5 - 4) / 2 = 4
    • 比较 nums[4] = 5nums[5] = 6
      • 5 < 6,意味着峰值在右侧。
      • 更新 left = mid + 1 = 5
  • 循环结束

    • left = 5, right = 5
    • 返回 left = 5,即峰值 6 的索引。
二分查找方法的优势与局限:
  • 优势

    • 时间效率高 :时间复杂度为 O(log n),适用于大规模数组。
    • 空间效率高 :空间复杂度为 O(1),只需常数级别的额外空间。
  • 局限

    • 不能找到所有峰值:该方法只返回一个峰值的索引,而不是所有峰值。
    • 依赖局部信息:如果需要全局信息(如所有峰值),需要其他方法。
边界条件分析:
  1. 单元素数组

    • 例如 nums = [1]
    • left = 0, right = 0
    • 直接返回 0,因为唯一元素是峰值。
  2. 两个元素数组

    • 例如 nums = [1, 2]
    • left = 0, right = 1.
    • mid = 0, 比较 nums[0] = 1nums[1] = 2
      • 1 < 2,更新 left = 1.
    • left = 1, right = 1
    • 返回 1,峰值为 2
  3. 全局递增或递减数组

    • 全局递增

      • 例如 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
  4. 多个峰值

    • 例如 nums = [1, 3, 2, 4, 1]
    • 可能返回 13,都符合峰值定义。
    • 二分查找会根据比较路径选择其中一个峰值。
为什么二分查找方法总能找到一个峰值?
  • 存在至少一个峰值

    • 题目假设 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)

    • 仅使用了常数级别的额外空间(变量 leftrightmid)。

总结:

二分查找方法通过利用数组的局部有序性,巧妙地缩小搜索范围,从而在 O(log n) 的时间复杂度内找到一个峰值。每次比较 nums[mid]nums[mid + 1],都能确保至少有一个峰值存在于更新后的搜索范围中,最终保证了算法的正确性和高效性。

相比于线性遍历法,二分查找法在处理大规模数组时表现尤为优越。然而,需要注意的是,该方法仅返回一个峰值的索引,若需要所有峰值,则需采用其他方法。

相关推荐
QuantumStack24 分钟前
【C++ 真题】B2003 输出第二个整数
开发语言·c++·算法
L_cl1 小时前
数据结构与算法——Java实现 32.堆
java·数据结构·算法
AutoAutoJack1 小时前
C#中,虚方法(virtual) 和 抽象方法(abstract)的应用说明
开发语言·数据结构·算法·架构·c#
伤心男孩拯救世界(Code King)1 小时前
【优选算法】--- 位运算
c++·算法
594h22 小时前
传智杯 第六届—C
数据结构·c++·算法
阳光男孩012 小时前
力扣1930. 长度为3的不同回文子序列
数据结构·算法·leetcode
夜雨翦春韭2 小时前
【代码随想录Day34】动态规划Part03
java·数据结构·算法·leetcode·动态规划
single5942 小时前
【优选算法】(第三十四篇)
java·开发语言·数据结构·c++·python·算法·leetcode
Rstln2 小时前
【线段树】个人练习-Leetcode-3161. Block Placement Queries
算法
Sheep Shaun2 小时前
希尔排序和直接插入排序
c语言·数据结构·算法·排序算法