🔍 你真的会二分查找吗?
二分查找是一种在有序数组中查找特定元素的高效算法。它通过不断将搜索范围缩小一半来快速定位目标值,时间复杂度为 O(log n)。
🤔 常见疑惑
在学习二分查找时,你可能会遇到这些问题:
- 循环条件是用
<=
还是<
? - 边界更新是用
mid-1
还是mid
? - 如何处理重复元素?
- 左闭右闭区间
[left, right]
和左闭右开区间[left, right)
如何选择?
让我们通过实例一步步解开这些疑惑。
📚 最基本的二分查找
💡 基本实现
javascript
// 基本的二分查找
function search(arr, target) {
let left = 0;
let right = arr.length - 1; // 左闭右闭区间 [left, right]
while (left <= right) {
// 区间有效时继续查找
const mid = left + Math.floor((right - left) / 2); // 防止整数溢出
if (arr[mid] < target) {
left = mid + 1; // 目标在右侧
} else if (arr[mid] > target) {
right = mid - 1; // 目标在左侧
} else {
return mid; // 找到目标值
}
}
return -1; // 目标值不存在
}
// 示例
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(search(arr, 7)); // 输出: 6(7在数组中的位置)
🔍 关键点解析
-
区间定义
- 使用闭区间
[left, right]
right
初始值为arr.length - 1
- 循环条件为
left <= right
- 使用闭区间
-
中间值计算
- 使用
left + (right - left) / 2
- 而不是
(left + right) / 2
- 防止整数溢出
- 使用
-
边界更新
- 目标在右侧:
left = mid + 1
- 目标在左侧:
right = mid - 1
- 保证区间不断缩小
- 目标在右侧:
💫 左闭右开区间 [left, right)
🔍 关键点解析
-
为什么使用
left <= right
?- 因为初始化时
right = arr.length - 1
- 区间是闭区间
[left, right]
,两端都可以取到 - 当
left == right
时,这个值也需要判断
- 因为初始化时
-
区间特点
- 左闭右闭区间:
[left, right]
- 每次都在一个确定的区间内查找
- 区间在循环过程中会逐渐缩小,但始终是有效的
- 左闭右闭区间:
💫 左闭右开区间 [left, right)
javascript
function search2(arr, target) {
let left = 0;
let right = arr.length; // 左闭右开区间 [left, right)
while (left < right) {
// 区间不为空时继续查找
const mid = left + Math.floor((right - left) / 2);
if (arr[mid] < target) {
left = mid + 1; // 目标在右侧
} else if (arr[mid] > target) {
right = mid; // 目标在左侧,注意不是 mid-1
} else {
return mid; // 找到目标值
}
}
return -1; // 目标值不存在
}
🔍 关键点解析
-
为什么使用
left < right
?- 因为初始化时
right = arr.length
- 区间是左闭右开
[left, right)
,右端点取不到 - 当
left == right
时,区间为空,没有值需要判断
- 因为初始化时
-
为什么
right = mid
而不是mid-1
?- 因为右区间是开区间,
right
指向的位置不在查找范围内 mid
可能是目标位置,不能跳过- 保持区间定义的一致性:右开区间的特点
- 因为右区间是开区间,
-
区间特点
- 左闭右开区间:
[left, right)
- 实际查找范围:
[left, right-1]
- 每次缩小区间时都保持左闭右开的特性
- 左闭右开区间:
🎯 练习题目
在掌握了基本原理后,建议练习以下题目:
- 704. 二分查找 - 基础二分查找
- 剑指 Offer 53 - I. 在排序数组中查找数字 I - 查找数字出现次数
🎯 进阶:查找最左侧边界
💡 问题引入
想象这样一个场景:
- 有一个升序数组,其中包含多个相同的目标值
- 需要找到最左侧的那个目标值的位置
- 必须使用二分查找来实现
虽然顺序查找也能解决这个问题,但我们的目标是用二分查找来优化时间复杂度。
🔍 实现思路
我们将在基本二分查找的基础上做一些巧妙的修改。关键在于:
- 即使找到目标值也不立即返回
- 继续向左边搜索,寻找可能存在的更左侧的目标值
- 使用左闭右闭区间
[left, right]
来实现
javascript
// 查找最左侧的目标值
function searchLeft(arr, target) {
let left = 0;
let right = arr.length - 1; // 左闭右闭区间
while (left <= right) {
// 区间有效时继续查找
const mid = left + Math.floor((right - left) / 2);
if (arr[mid] < target) {
left = mid + 1; // 目标在右侧
} else if (arr[mid] > target) {
right = mid - 1; // 目标在左侧
} else {
right = mid - 1; // ⭐ 关键:收缩右边界,继续向左找
}
}
// 边界条件检查
if (left >= arr.length || arr[left] !== target) {
return -1; // 目标值不存在
}
return left; // 返回最左侧的位置
}
// 示例
const arr = [1, 2, 3, 4, 5, 6, 7, 7, 7, 7, 7, 8, 9, 10];
console.log(searchLeft(arr, 7)); // 输出: 6(第一个7的位置)
🔍 代码要点解析
-
核心思路
- 即使找到目标值也不返回
- 通过收缩右边界,向左继续寻找
- 最终
left
指向的就是最左侧的目标值
-
边界处理
- 循环结束后,
left
指向第一个大于等于target
的位置 - 需要检查:
left
是否越界- 该位置的值是否等于目标值
- 循环结束后,
-
为什么这样做有效?
- 当找到目标值时,不急于返回
- 收缩右边界,继续在左半部分查找
- 保证了找到的是最左侧的目标值
💫 左闭右开版本
javascript
// 查找最左侧的目标值(左闭右开版本)
function searchLeft2(arr, target) {
let left = 0;
let right = arr.length; // 左闭右开:[left, right)
while (left < right) {
// 区间不为空时继续查找
const mid = left + Math.floor((right - left) / 2);
if (arr[mid] < target) {
left = mid + 1; // 目标在右侧
} else if (arr[mid] > target) {
right = mid; // 目标在左侧,注意不是 mid-1
} else {
right = mid; // ⭐ 关键:收缩右边界,继续向左找
}
}
// 边界条件检查
if (left >= arr.length || arr[left] !== target) {
return -1; // 目标值不存在
}
return left; // 返回最左侧的位置
}
// 示例
const arr2 = [1, 2, 3, 4, 5, 6, 7, 7, 7, 7, 7, 8, 9, 10];
console.log(searchLeft2(arr2, 7)); // 输出: 6(第一个7的位置)
🔍 左闭右开版本的特点
-
区间定义
- 搜索范围:
[left, right)
right
初始值为arr.length
- 循环条件为
left < right
- 搜索范围:
-
边界处理
- 当
arr[mid] >= target
时,使用right = mid
- 保持右开区间的特性
- 最终
left
指向目标位置
- 当
🎯 进阶:查找最右侧边界
💡 问题转化
查找最右侧边界可以看作是查找最左侧边界的镜像问题:
- 当找到目标值时,不是收缩右边界
- 而是扩展左边界,继续向右寻找
javascript
// 查找最右侧的目标值
function searchRight(arr, target) {
let left = 0;
let right = arr.length - 1; // 左闭右闭区间
while (left <= right) {
// 区间有效时继续查找
const mid = left + Math.floor((right - left) / 2);
if (arr[mid] < target) {
left = mid + 1; // 目标在右侧
} else if (arr[mid] > target) {
right = mid - 1; // 目标在左侧
} else {
left = mid + 1; // ⭐ 关键:扩展左边界,继续向右找
}
}
// 边界条件检查
if (right < 0 || arr[right] !== target) {
return -1; // 目标值不存在
}
return right; // 返回最右侧的位置
}
// 示例
const arr = [1, 2, 3, 4, 5, 6, 7, 7, 7, 7, 7, 8, 9, 10];
console.log(searchRight(arr, 7)); // 输出: 10(最后一个7的位置)
🔍 代码要点解析
-
核心思路
- 找到目标值时不返回
- 通过扩展左边界,向右继续寻找
- 最终
right
指向的就是最右侧的目标值
-
边界处理
- 循环结束后,
right
指向最后一个等于target
的位置 - 需要检查:
right
是否越界(小于 0)- 该位置的值是否等于目标值
- 循环结束后,
-
与查找最左侧边界的区别
- 找到目标值时的处理方向相反
- 最终返回的是
right
而不是left
- 边界检查条件也相应调整
💫 左闭右开版本
左闭右闭版本
js
// 查找最右侧的目标值(左闭右开版本)
function searchRight2(arr, target) {
let left = 0;
let right = arr.length; // 左闭右开:[left, right)
while (left < right) {
// 区间不为空时继续查找
const mid = left + Math.floor((right - left) / 2);
if (arr[mid] < target) {
left = mid + 1; // 目标在右侧
} else if (arr[mid] > target) {
right = mid; // 目标在左侧,注意不是 mid-1
} else {
left = mid + 1; // ⭐ 关键:扩展左边界,继续向右找
}
}
// 边界条件检查
if (right <= 0 || arr[right - 1] !== target) {
return -1; // 目标值不存在
}
return right - 1; // 返回最右侧的位置
}
// 示例
const arr2 = [1, 2, 3, 4, 5, 6, 7, 7, 7, 7, 7, 8, 9, 10];
console.log(searchRight2(arr2, 7)); // 输出: 10(最后一个7的位置)
需要注意的点是:在循环后进行判断的时候是 right-1 进行判断,因为 right 在初始赋值和循环条件赋值时都没有-1,所以最后进行边界判断和返回下标时需要-1
📊 二分查找实现对比
🔍 基本实现对比
实现细节 | 左闭右闭区间 [left, right] |
左闭右开区间 [left, right) |
---|---|---|
初始化右边界 | right = arr.length - 1 |
right = arr.length |
循环条件 | while (left <= right) |
while (left < right) |
左边界更新 | left = mid + 1 |
left = mid + 1 |
右边界更新 | right = mid - 1 |
right = mid |
🎯 边界查找对比
查找类型 | 左闭右闭区间 [left, right] |
左闭右开区间 [left, right) |
---|---|---|
最左侧边界 | right = mid - 1 |
right = mid |
最右侧边界 | left = mid + 1 |
left = mid + 1 |
💡 实践建议
- 对于初学者,建议使用左闭右闭区间
[left, right]
:- 更直观易理解
- 边界处理更统一
- 不需要额外的索引调整
- 使用左闭右开区间时需要注意:
- 查找最右侧边界时,返回结果需要
-1
调整 - 边界条件判断更复杂
- 查找最右侧边界时,返回结果需要