1. 问题描述与核心思路
Leetcode第34题"在排序数组中查找元素的第一个和最后一个位置"是二分查找算法的经典应用场景。题目要求在一个非递减顺序排列的整数数组中,找到给定目标值的开始位置和结束位置。如果目标值不存在于数组中,则返回[-1, -1]。
这个问题的难点在于:
- 数组可能包含重复元素
- 需要同时找到起始和结束位置
- 时间复杂度必须控制在O(log n)级别
提示:当看到"排序数组"和"O(log n)时间复杂度"这两个条件同时出现时,二分查找几乎就是唯一正确的解法选择。
2. 二分查找算法基础回顾
2.1 标准二分查找实现
在解决这个问题前,我们先回顾标准二分查找的JavaScript实现:
javascript复制function binarySearch(nums, target) {
let left = 0, right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
这个基础版本只能找到一个匹配项的位置,无法处理重复元素的情况。
2.2 二分查找的变体
为了找到目标值的边界,我们需要实现两种变体:
- 查找第一个等于目标值的位置
- 查找最后一个等于目标值的位置
这两种变体的核心区别在于当nums[mid] == target时的处理方式不同。
3. 解决方案实现
3.1 查找第一个位置
查找第一个等于目标值的位置的实现:
javascript复制function findFirst(nums, target) {
let left = 0, right = nums.length - 1;
let first = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] >= target) {
right = mid - 1;
if (nums[mid] === target) first = mid;
} else {
left = mid + 1;
}
}
return first;
}
关键点:
- 当
nums[mid] == target时,我们仍然继续向左搜索(right = mid - 1) - 记录所有遇到的等于target的位置
- 最终返回的是最左边的那个位置
3.2 查找最后一个位置
查找最后一个等于目标值的位置的实现:
javascript复制function findLast(nums, target) {
let left = 0, right = nums.length - 1;
let last = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] <= target) {
left = mid + 1;
if (nums[mid] === target) last = mid;
} else {
right = mid - 1;
}
}
return last;
}
关键点:
- 当
nums[mid] == target时,我们继续向右搜索(left = mid + 1) - 记录所有遇到的等于target的位置
- 最终返回的是最右边的那个位置
3.3 完整解决方案
将两个辅助函数组合起来:
javascript复制function searchRange(nums, target) {
return [findFirst(nums, target), findLast(nums, target)];
}
4. 边界条件与测试用例
4.1 常见边界情况
- 空数组:
nums = [], target = 0→ 返回[-1, -1] - 单个元素匹配:
nums = [1], target = 1→ 返回[0, 0] - 单个元素不匹配:
nums = [1], target = 2→ 返回[-1, -1] - 所有元素相同:
nums = [1,1,1], target = 1→ 返回[0, 2] - 目标值在开头:
nums = [1,2,3], target = 1→ 返回[0, 0] - 目标值在结尾:
nums = [1,2,3], target = 3→ 返回[2, 2]
4.2 测试用例验证
javascript复制const testCases = [
{ nums: [5,7,7,8,8,10], target: 8, expected: [3,4] },
{ nums: [5,7,7,8,8,10], target: 6, expected: [-1,-1] },
{ nums: [], target: 0, expected: [-1,-1] },
{ nums: [1], target: 1, expected: [0,0] },
{ nums: [1,1,1,1], target: 1, expected: [0,3] }
];
testCases.forEach(({nums, target, expected}, i) => {
const result = searchRange(nums, target);
console.log(`Test case ${i+1}:`,
JSON.stringify(result) === JSON.stringify(expected) ? "PASS" : "FAIL",
`Expected ${expected}, got ${result}`);
});
5. 算法复杂度分析
-
时间复杂度:O(log n)
findFirst和findLast各自执行一次二分查找- 两次二分查找的时间复杂度都是O(log n)
- 总体时间复杂度保持为O(log n)
-
空间复杂度:O(1)
- 只使用了常数级别的额外空间
6. 常见错误与调试技巧
6.1 常见实现错误
-
无限循环:
- 通常由于边界条件处理不当导致
- 确保循环条件为
left <= right而非left < right
-
漏掉边界元素:
- 忘记检查
left或right指针指向的元素 - 在循环结束后可能需要额外检查
- 忘记检查
-
整数溢出:
- 计算中点时
(left + right) / 2可能导致溢出 - 更安全的写法:
left + Math.floor((right - left) / 2)
- 计算中点时
6.2 调试技巧
-
打印中间状态:
javascript复制console.log(`left=${left}, right=${right}, mid=${mid}, nums[mid]=${nums[mid]}`); -
使用小数组测试:
- 先用手动计算验证小数组(长度3-5)的结果
- 再逐步增加数组规模
-
可视化工具:
- 使用Leetcode的playground功能逐步执行
- 或者在本地使用调试器设置断点
7. 性能优化与变体
7.1 一次二分查找实现
我们可以优化算法,在一次二分查找中同时找到第一个和最后一个位置:
javascript复制function searchRangeOptimized(nums, target) {
let left = 0, right = nums.length - 1;
let first = -1, last = -1;
// 查找第一个位置
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] >= target) {
right = mid - 1;
if (nums[mid] === target) first = mid;
} else {
left = mid + 1;
}
}
// 如果没找到第一个位置,直接返回
if (first === -1) return [-1, -1];
// 重置指针,查找最后一个位置
left = first;
right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] <= target) {
left = mid + 1;
if (nums[mid] === target) last = mid;
} else {
right = mid - 1;
}
}
return [first, last];
}
7.2 其他语言实现思路
虽然本文以JavaScript为例,但算法思想适用于所有语言:
- Python: 利用bisect模块的bisect_left和bisect_right
- Java: 类似的二分查找实现,注意整数溢出问题
- C++: 使用STL的lower_bound和upper_bound
8. 实际应用场景
这种边界查找的二分变体在实际开发中有广泛应用:
- 日志系统:查找特定时间范围内的日志条目
- 数据库查询:范围查询优化
- 数据分析:统计某个值出现的频率和分布
- 游戏开发:快速查找分数区间内的玩家
注意事项:在实际工程中,当数据量不大时(如n<100),线性扫描可能比二分查找更高效,因为二分查找的常数因子较大且实现复杂。应根据具体场景选择最合适的算法。
