35-二分搜索-搜索插入位置

数组 | 二分搜索


问题描述

给定一个排序数组和一个目标值,

  • 在数组中找到目标值,并返回其索引。
  • 如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

要求:

  • 时间复杂度必须为 O(log n),即需要使用二分查找算法。

示例

  1. 示例 1:

    • 输入: nums = [1, 3, 5, 6], target = 5
    • 输出: 2
    • 解释: 目标值 5 在数组中的索引为 2
  2. 示例 2:

    • 输入: nums = [1, 3, 5, 6], target = 2
    • 输出: 1
    • 解释: 目标值 2 不在数组中,但它应被插入到索引 1 处,以保持数组的有序性。
  3. 示例 3:

    • 输入: nums = [1, 3, 5, 6], target = 7
    • 输出: 4
    • 解释: 目标值 7 不在数组中,应插入到索引 4 处。

二分查找简介

二分查找是一种在 有序数组 中查找某个特定元素的高效算法。其核心思想是通过反复将查找范围减半,直到找到目标或确定其应插入的位置。

时间复杂度为 O(log n),因为每次操作都将查找范围减少一半,非常高效。

闭区间写法详解

你提供的 JavaScript 函数 lowerBound 实现了一个闭区间的二分查找。让我们逐行分析并理解其工作原理。

代码回顾

var lowerBound = function(nums, target) {
    let left = 0, right = nums.length - 1; // 闭区间 [left, right]
    while (left <= right) { // 区间不为空
        // 循环不变量:
        // nums[left-1] < target
        // nums[right+1] >= target
        const mid = Math.floor((left + right) / 2);
        if (nums[mid] < target) {
            left = mid + 1; // 范围缩小到 [mid+1, right]
        } else {
            right = mid - 1; // 范围缩小到 [left, mid-1]
        }
    }
    return left;
}

变量初始化

  • left: 起始索引,初始化为 0
  • right: 终止索引,初始化为 nums.length - 1
  • 闭区间 [left, right]: 表示当前查找的有效范围,包括 leftright 两个端点。

循环条件

while (left <= right)

循环继续的条件是 left 小于或等于 right,这保证了查找范围至少包含一个元素。当 left > right 时,查找范围为空,循环终止。

循环不变量

在每次循环迭代中,保持以下不变量:

  • nums[left - 1] < target: 表示在当前查找范围左侧的所有元素都小于目标值。
  • nums[right + 1] >= target: 表示在当前查找范围右侧的所有元素都大于或等于目标值。

这些不变量帮助确保算法在收敛时能够返回正确的插入位置。

计算中点

const mid = Math.floor((left + right) / 2);

计算当前查找范围的中间索引 mid,使用 Math.floor 确保结果为整数。

比较与调整范围

if (nums[mid] < target) {
    left = mid + 1; // 范围缩小到 [mid+1, right]
} else {
    right = mid - 1; // 范围缩小到 [left, mid-1]
}
  • 如果 nums[mid] < target:

    • 这意味着目标值应位于 mid 的右侧。
    • 因此,将 left 更新为 mid + 1,将查找范围缩小到 [mid + 1, right]
  • 否则:

    • 这意味着目标值应位于 mid 的左侧,或者就是 nums[mid] 本身。
    • 因此,将 right 更新为 mid - 1,将查找范围缩小到 [left, mid - 1]

返回结果

return left;

循环结束后,left 的值即为目标值在数组中应该插入的位置。如果目标值存在于数组中,left 会指向它的索引;否则,left 会指向它应插入的位置,以保持数组有序。

示例演示

让我们通过一个具体的例子来演示该算法的工作过程。

示例 1

输入: nums = [1, 3, 5, 6], target = 5
期望输出: 2

步骤:

  1. 初始化:

    • left = 0
    • right = 3 (nums.length - 1)
    • 数组索引: 0 1 2 3
    • 数组值: [1, 3, 5, 6]
  2. 第一次循环:

    • mid = Math.floor((0 + 3) / 2) = 1
    • nums[mid] = nums[1] = 3
    • 比较 3 < 5,条件满足。
    • 更新 left = mid + 1 = 2
  3. 第二次循环:

    • mid = Math.floor((2 + 3) / 2) = 2
    • nums[mid] = nums[2] = 5
    • 比较 5 < 5,条件不满足。
    • 更新 right = mid - 1 = 1
  4. 循环结束条件: left (2) > right (1),循环终止。

  5. 返回结果: left = 2

解释: 目标值 5 存在于数组中,位于索引 2

示例 2

输入: nums = [1, 3, 5, 6], target = 2
期望输出: 1

步骤:

  1. 初始化:

    • left = 0
    • right = 3
    • 数组索引: 0 1 2 3
    • 数组值: [1, 3, 5, 6]
  2. 第一次循环:

    • mid = Math.floor((0 + 3) / 2) = 1
    • nums[mid] = nums[1] = 3
    • 比较 3 < 2,条件不满足。
    • 更新 right = mid - 1 = 0
  3. 第二次循环:

    • mid = Math.floor((0 + 0) / 2) = 0
    • nums[mid] = nums[0] = 1
    • 比较 1 < 2,条件满足。
    • 更新 left = mid + 1 = 1
  4. 循环结束条件: left (1) > right (0),循环终止。

  5. 返回结果: left = 1

解释: 目标值 2 不存在于数组中,应插入在索引 1 处,以保持数组有序。

示例 3

输入: nums = [1, 3, 5, 6], target = 7
期望输出: 4

步骤:

  1. 初始化:

    • left = 0
    • right = 3
    • 数组索引: 0 1 2 3
    • 数组值: [1, 3, 5, 6]
  2. 第一次循环:

    • mid = Math.floor((0 + 3) / 2) = 1
    • nums[mid] = nums[1] = 3
    • 比较 3 < 7,条件满足。
    • 更新 left = mid + 1 = 2
  3. 第二次循环:

    • mid = Math.floor((2 + 3) / 2) = 2
    • nums[mid] = nums[2] = 5
    • 比较 5 < 7,条件满足。
    • 更新 left = mid + 1 = 3
  4. 第三次循环:

    • mid = Math.floor((3 + 3) / 2) = 3
    • nums[mid] = nums[3] = 6
    • 比较 6 < 7,条件满足。
    • 更新 left = mid + 1 = 4
  5. 循环结束条件: left (4) > right (3),循环终止。

  6. 返回结果: left = 4

解释: 目标值 7 不存在于数组中,应插入在索引 4 处,即数组末尾,以保持有序性。

算法优势

  1. 高效性: 每次循环将查找范围减半,极大地减少了需要检查的元素数量,适用于大规模数据。

  2. 简洁性: 闭区间写法通过调整 leftright 的位置,确保了所有可能的插入位置都被正确考虑。

  3. 适应性: 无论目标值是否存在,算法都能返回最合适的插入位置。

进一步理解闭区间写法

闭区间 vs 开区间

在二分查找中,常见的写法有 闭区间开区间 两种:

  • 闭区间 [left, right]:

    • 包含 leftright 两端点。
    • 条件为 left <= right
  • 开区间 [left, right):

    • 包含 left,不包含 right
    • 条件为 left < right

你使用的是闭区间写法。理解两种写法的差异有助于掌握不同情境下的二分查找实现。

为什么选择闭区间写法?

闭区间写法在某些情况下更直观,特别是在需要确定确切存在性或插入位置的场景中。它确保了每个元素都被考虑到,并且通过调整 leftright 的位置,能更好地处理边界条件。

完整代码与详细注释

为了帮助你更清楚地理解整个过程,下面是带有详细注释的完整代码:

/**
 * 查找目标值在排序数组中的索引,如果不存在则返回其插入位置。
 * 使用闭区间二分查找。
 * @param {number[]} nums - 有序整数数组。
 * @param {number} target - 目标值。
 * @return {number} - 目标值的索引或插入位置。
 */
var lowerBound = function(nums, target) {
    let left = 0; // 左边界初始化为0
    let right = nums.length - 1; // 右边界初始化为数组的最后一个索引

    // 循环条件:左边界 <= 右边界,表示查找范围不为空
    while (left <= right) {
        // 计算中间索引,避免溢出
        const mid = Math.floor((left + right) / 2);
        // 输出当前状态,便于调试
        // console.log(`left: ${left}, right: ${right}, mid: ${mid}, nums[mid]: ${nums[mid]}`);

        if (nums[mid] < target) {
            // 如果中间元素小于目标值,目标应在右半部分
            left = mid + 1; // 更新左边界,缩小查找范围到 [mid + 1, right]
        } else {
            // 如果中间元素大于或等于目标值,目标应在左半部分或即为mid
            right = mid - 1; // 更新右边界,缩小查找范围到 [left, mid - 1]
        }
    }

    // 循环结束后,left 是目标值应插入的位置
    return left;
};

代码注释详解

  1. 函数签名:

    • nums: 有序整数数组。
    • target: 需要查找或插入的目标值。
  2. 变量初始化:

    • left = 0: 初始左边界。
    • right = nums.length - 1: 初始右边界。
  3. 循环条件:

    • while (left <= right): 确保查找范围至少包含一个元素。
  4. 计算中点:

    • const mid = Math.floor((left + right) / 2): 计算中间索引,使用 Math.floor 取整。
  5. 比较与调整:

    • if (nums[mid] < target):
      • 目标在右侧,更新 left = mid + 1
    • else:
      • 目标在左侧或即为 mid,更新 right = mid - 1
  6. 返回结果:

    • return left: 目标值的插入位置。

代码调试辅助

在实际调试过程中,你可以在循环内部添加 console.log 语句,以查看每次迭代的中间状态。例如:

console.log(`left: ${left}, right: ${right}, mid: ${mid}, nums[mid]: ${nums[mid]}`);

这有助于理解算法在每一步如何调整查找范围。

算法正确性证明

目标值存在时

如果目标值存在于数组中,算法最终会找到它,并返回其索引。例如,在示例 1 中,5 存在于数组中,索引为 2

目标值不存在时

如果目标值不存在,left 将指向它应插入的位置,确保数组保持有序。例如,在示例 2 和 3 中,目标值 27 不存在,分别应插入在索引 14 处。

边界条件处理

  • 空数组:

    • 如果 nums 为空,right = -1,满足 left (0) > right (-1),返回 0,即插入位置在数组开始。
  • 目标值小于所有元素:

    • 例如,nums = [3, 4, 5], target = 1
    • 最终 left = 0,即插入位置在数组开头。
  • 目标值大于所有元素:

    • 例如,nums = [1, 2, 3], target = 4
    • 最终 left = 3,即插入位置在数组末尾。

复杂度分析

时间复杂度

  • O(log n): 每次循环将查找范围减半,查找次数与数组长度的对数成正比。

空间复杂度

  • O(1): 使用常数级别的额外空间,仅需存储几个变量 (left, right, mid)。

实际应用

这个二分查找算法不仅适用于查找和插入位置,还广泛应用于以下场景:

  1. 搜索问题: 在有序数据中快速查找元素。
  2. 数值定位: 例如,查找某个阈值或特定条件下的最小/最大值。
  3. 优化问题: 在解决需要快速决策的优化问题时,通过二分搜索缩小决策范围。

总结

通过上述详细的代码分析和示例演示,我们深入理解了如何在 JavaScript 中使用闭区间二分查找实现查找或插入的位置。二分查找凭借其高效的时间复杂度,非常适合处理大规模有序数据的查找问题。

关键点回顾:

  • 闭区间写法: 包含 leftright,循环条件为 left <= right
  • 中点计算: 使用 Math.floor((left + right) / 2) 确保整数索引。
  • 调整查找范围: 根据中点值与目标值的比较结果,更新 leftright
  • 返回结果: 最终 left 指向目标值的正确位置,无论其是否存在于数组中。

希望这个详细的解释能帮助你更好地理解二分查找算法以及如何在 JavaScript 中实现它。如果你还有其他问题或需要进一步的说明,请随时告诉我!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值