数组 | 二分搜索
问题描述
给定一个排序数组和一个目标值,
- 在数组中找到目标值,并返回其索引。
- 如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
要求:
- 时间复杂度必须为 O(log n),即需要使用二分查找算法。
示例
-
示例 1:
- 输入:
nums = [1, 3, 5, 6]
,target = 5
- 输出:
2
- 解释: 目标值
5
在数组中的索引为2
。
- 输入:
-
示例 2:
- 输入:
nums = [1, 3, 5, 6]
,target = 2
- 输出:
1
- 解释: 目标值
2
不在数组中,但它应被插入到索引1
处,以保持数组的有序性。
- 输入:
-
示例 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]: 表示当前查找的有效范围,包括
left
和right
两个端点。
循环条件
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
步骤:
-
初始化:
left = 0
right = 3
(nums.length - 1
)- 数组索引:
0 1 2 3
- 数组值:
[1, 3, 5, 6]
-
第一次循环:
mid = Math.floor((0 + 3) / 2) = 1
nums[mid] = nums[1] = 3
- 比较
3 < 5
,条件满足。 - 更新
left = mid + 1 = 2
-
第二次循环:
mid = Math.floor((2 + 3) / 2) = 2
nums[mid] = nums[2] = 5
- 比较
5 < 5
,条件不满足。 - 更新
right = mid - 1 = 1
-
循环结束条件:
left (2) > right (1)
,循环终止。 -
返回结果:
left = 2
解释: 目标值 5
存在于数组中,位于索引 2
。
示例 2
输入: nums = [1, 3, 5, 6]
, target = 2
期望输出: 1
步骤:
-
初始化:
left = 0
right = 3
- 数组索引:
0 1 2 3
- 数组值:
[1, 3, 5, 6]
-
第一次循环:
mid = Math.floor((0 + 3) / 2) = 1
nums[mid] = nums[1] = 3
- 比较
3 < 2
,条件不满足。 - 更新
right = mid - 1 = 0
-
第二次循环:
mid = Math.floor((0 + 0) / 2) = 0
nums[mid] = nums[0] = 1
- 比较
1 < 2
,条件满足。 - 更新
left = mid + 1 = 1
-
循环结束条件:
left (1) > right (0)
,循环终止。 -
返回结果:
left = 1
解释: 目标值 2
不存在于数组中,应插入在索引 1
处,以保持数组有序。
示例 3
输入: nums = [1, 3, 5, 6]
, target = 7
期望输出: 4
步骤:
-
初始化:
left = 0
right = 3
- 数组索引:
0 1 2 3
- 数组值:
[1, 3, 5, 6]
-
第一次循环:
mid = Math.floor((0 + 3) / 2) = 1
nums[mid] = nums[1] = 3
- 比较
3 < 7
,条件满足。 - 更新
left = mid + 1 = 2
-
第二次循环:
mid = Math.floor((2 + 3) / 2) = 2
nums[mid] = nums[2] = 5
- 比较
5 < 7
,条件满足。 - 更新
left = mid + 1 = 3
-
第三次循环:
mid = Math.floor((3 + 3) / 2) = 3
nums[mid] = nums[3] = 6
- 比较
6 < 7
,条件满足。 - 更新
left = mid + 1 = 4
-
循环结束条件:
left (4) > right (3)
,循环终止。 -
返回结果:
left = 4
解释: 目标值 7
不存在于数组中,应插入在索引 4
处,即数组末尾,以保持有序性。
算法优势
-
高效性: 每次循环将查找范围减半,极大地减少了需要检查的元素数量,适用于大规模数据。
-
简洁性: 闭区间写法通过调整
left
和right
的位置,确保了所有可能的插入位置都被正确考虑。 -
适应性: 无论目标值是否存在,算法都能返回最合适的插入位置。
进一步理解闭区间写法
闭区间 vs 开区间
在二分查找中,常见的写法有 闭区间 和 开区间 两种:
-
闭区间 [left, right]:
- 包含
left
和right
两端点。 - 条件为
left <= right
。
- 包含
-
开区间 [left, right):
- 包含
left
,不包含right
。 - 条件为
left < right
。
- 包含
你使用的是闭区间写法。理解两种写法的差异有助于掌握不同情境下的二分查找实现。
为什么选择闭区间写法?
闭区间写法在某些情况下更直观,特别是在需要确定确切存在性或插入位置的场景中。它确保了每个元素都被考虑到,并且通过调整 left
和 right
的位置,能更好地处理边界条件。
完整代码与详细注释
为了帮助你更清楚地理解整个过程,下面是带有详细注释的完整代码:
/**
* 查找目标值在排序数组中的索引,如果不存在则返回其插入位置。
* 使用闭区间二分查找。
* @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;
};
代码注释详解
-
函数签名:
nums
: 有序整数数组。target
: 需要查找或插入的目标值。
-
变量初始化:
left = 0
: 初始左边界。right = nums.length - 1
: 初始右边界。
-
循环条件:
while (left <= right)
: 确保查找范围至少包含一个元素。
-
计算中点:
const mid = Math.floor((left + right) / 2)
: 计算中间索引,使用Math.floor
取整。
-
比较与调整:
if (nums[mid] < target)
:- 目标在右侧,更新
left = mid + 1
。
- 目标在右侧,更新
else
:- 目标在左侧或即为
mid
,更新right = mid - 1
。
- 目标在左侧或即为
-
返回结果:
return left
: 目标值的插入位置。
代码调试辅助
在实际调试过程中,你可以在循环内部添加 console.log
语句,以查看每次迭代的中间状态。例如:
console.log(`left: ${left}, right: ${right}, mid: ${mid}, nums[mid]: ${nums[mid]}`);
这有助于理解算法在每一步如何调整查找范围。
算法正确性证明
目标值存在时
如果目标值存在于数组中,算法最终会找到它,并返回其索引。例如,在示例 1 中,5
存在于数组中,索引为 2
。
目标值不存在时
如果目标值不存在,left
将指向它应插入的位置,确保数组保持有序。例如,在示例 2 和 3 中,目标值 2
和 7
不存在,分别应插入在索引 1
和 4
处。
边界条件处理
-
空数组:
- 如果
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
)。
实际应用
这个二分查找算法不仅适用于查找和插入位置,还广泛应用于以下场景:
- 搜索问题: 在有序数据中快速查找元素。
- 数值定位: 例如,查找某个阈值或特定条件下的最小/最大值。
- 优化问题: 在解决需要快速决策的优化问题时,通过二分搜索缩小决策范围。
总结
通过上述详细的代码分析和示例演示,我们深入理解了如何在 JavaScript 中使用闭区间二分查找实现查找或插入的位置。二分查找凭借其高效的时间复杂度,非常适合处理大规模有序数据的查找问题。
关键点回顾:
- 闭区间写法: 包含
left
和right
,循环条件为left <= right
。 - 中点计算: 使用
Math.floor((left + right) / 2)
确保整数索引。 - 调整查找范围: 根据中点值与目标值的比较结果,更新
left
或right
。 - 返回结果: 最终
left
指向目标值的正确位置,无论其是否存在于数组中。
希望这个详细的解释能帮助你更好地理解二分查找算法以及如何在 JavaScript 中实现它。如果你还有其他问题或需要进一步的说明,请随时告诉我!