1. 问题背景与需求分析
今天我们要解决的是Leetcode第34题——"在排序数组中查找元素的第一个和最后一个位置"。这是一道经典的二分查找变种问题,也是面试中的高频考题。题目要求我们设计一个时间复杂度为O(log n)的算法,在给定的升序排列数组中,找到目标值出现的起始和结束位置。
这道题之所以被标记为中等难度,是因为它考察了对基础二分查找算法的灵活运用能力。普通的二分查找只能确定目标值是否存在,而本题需要我们在找到目标值后,继续向两侧扩展搜索边界。这在实际开发中非常常见,比如我们需要统计某个时间范围内的日志记录,或者分析用户行为数据中的连续事件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JavaScript中的二分查找实现
2.1 基础二分查找框架
在JavaScript中实现二分查找,我们首先需要掌握其基本结构。以下是一个标准的二分查找实现:
javascript复制function binarySearch(nums, target) {
let left = 0;
let 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时,我们不立即返回,而是继续向左搜索:
javascript复制function findLeftBound(nums, target) {
let left = 0;
let right = nums.length - 1;
let index = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] >= target) {
right = mid - 1;
} else {
left = mid + 1;
}
if (nums[mid] === target) {
index = mid;
}
}
return index;
}
同理,右边界查找则需要继续向右搜索:
javascript复制function findRightBound(nums, target) {
let left = 0;
let right = nums.length - 1;
let index = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] <= target) {
left = mid + 1;
} else {
right = mid - 1;
}
if (nums[mid] === target) {
index = mid;
}
}
return index;
}
3. 完整解决方案实现
3.1 合并左右边界查找
将上述两个函数合并,我们可以得到完整的解决方案:
javascript复制function searchRange(nums, target) {
function findLeft() {
let left = 0;
let right = nums.length - 1;
let index = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] >= target) {
right = mid - 1;
} else {
left = mid + 1;
}
if (nums[mid] === target) {
index = mid;
}
}
return index;
}
function findRight() {
let left = 0;
let right = nums.length - 1;
let index = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] <= target) {
left = mid + 1;
} else {
right = mid - 1;
}
if (nums[mid] === target) {
index = mid;
}
}
return index;
}
const left = findLeft();
const right = findRight();
return [left, right];
}
3.2 优化与边界条件处理
在实际编码中,我们还需要考虑一些边界条件:
- 空数组情况:直接返回[-1, -1]
- 目标值不存在:当左边界为-1时,直接返回[-1, -1]
- 单元素数组:需要正确处理
优化后的版本如下:
javascript复制function searchRange(nums, target) {
if (nums.length === 0) return [-1, -1];
function findBound(isLeft) {
let left = 0;
let right = nums.length - 1;
let index = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
index = mid;
if (isLeft) {
right = mid - 1;
} else {
left = mid + 1;
}
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return index;
}
const left = findBound(true);
if (left === -1) return [-1, -1];
const right = findBound(false);
return [left, right];
}
这个版本通过一个统一的findBound函数,通过isLeft参数控制搜索方向,减少了代码重复。
4. 算法复杂度分析与测试用例
4.1 时间复杂度分析
由于我们进行了两次二分查找(一次找左边界,一次找右边界),每次二分查找的时间复杂度都是O(log n),因此总的时间复杂度仍然是O(log n)。这满足了题目要求。
空间复杂度为O(1),因为我们只使用了常数级别的额外空间。
4.2 测试用例设计
为了验证我们的解决方案,我们需要设计全面的测试用例:
javascript复制// 基础测试
console.log(searchRange([5,7,7,8,8,10], 8)); // [3,4]
console.log(searchRange([5,7,7,8,8,10], 6)); // [-1,-1]
// 边界测试
console.log(searchRange([], 0)); // [-1,-1]
console.log(searchRange([1], 1)); // [0,0]
console.log(searchRange([2,2], 2)); // [0,1]
// 性能测试
const largeArray = new Array(1000000).fill(5);
largeArray[999999] = 6;
console.log(searchRange(largeArray, 6)); // [999999,999999]
4.3 实际运行中的注意事项
在实际编码面试或竞赛中,有几个常见的陷阱需要注意:
-
整数溢出:在计算mid时,(left + right)可能会超过JavaScript的安全整数范围。虽然现代JavaScript引擎使用64位浮点数表示所有数字,不会有溢出问题,但在其他语言中需要注意。更安全的写法是:
mid = left + Math.floor((right - left) / 2) -
循环终止条件:确保while循环的条件是left <= right而不是left < right,否则可能会漏掉某些情况。
-
更新边界时的+1/-1:在更新left和right时,一定要记得+1或-1,否则可能会导致无限循环。
5. 扩展思考与实际应用
5.1 二分查找的变种问题
掌握了这道题的解法后,我们可以解决一系列类似的二分查找变种问题:
- 查找第一个大于等于目标值的位置
- 查找最后一个小于等于目标值的位置
- 查找峰值元素
- 在旋转排序数组中查找目标值
这些问题的核心思想都是通过调整二分查找的条件和边界更新策略来适应不同的需求。
5.2 实际开发中的应用场景
这种边界查找算法在实际开发中有广泛的应用:
- 日志分析:查找特定时间范围内的日志记录
- 用户行为分析:统计某个事件的持续时间
- 数据库查询:优化范围查询的性能
- 游戏开发:处理碰撞检测或物理引擎中的连续事件
5.3 JavaScript特有的性能考虑
在JavaScript中实现算法时,还需要考虑一些语言特有的性能因素:
- 数组访问:JavaScript引擎对数组访问有优化,但频繁的数组访问仍会影响性能
- 函数调用:内联小函数可以减少调用开销
- 类型转换:确保比较操作不会触发隐式类型转换
- JIT优化:现代JavaScript引擎会对热点代码进行优化,保持代码简单有助于JIT优化
6. 常见错误与调试技巧
6.1 常见错误模式
在解决这类问题时,新手常犯的错误包括:
- 忘记处理空数组或单元素数组的情况
- 在更新边界时忘记+1或-1,导致无限循环
- 混淆左边界和右边界的查找逻辑
- 在找到目标值后立即返回,而不是继续搜索边界
6.2 调试技巧
当算法出现问题时,可以采用以下调试方法:
- 打印中间变量:在循环中打印left、right和mid的值
- 使用小测试用例:先用简单的数组(如[1,2,2,3])测试
- 逐步执行:使用调试器逐步执行代码,观察变量变化
- 边界值测试:专门测试数组开头、结尾和中间的目标值
6.3 性能优化建议
对于大规模数据,还可以考虑以下优化:
- 提前终止:如果在查找左边界时返回-1,可以直接返回[-1,-1]而不必查找右边界
- 记忆化:如果多次查询同一个数组,可以预先建立索引
- 并行搜索:在支持Web Worker的环境中,可以并行搜索左右边界
7. 与其他语言的实现对比
虽然我们使用JavaScript实现了这个算法,但了解其他语言的实现方式也有助于加深理解:
7.1 Python实现对比
Python的实现更加简洁,可以利用bisect模块:
python复制import bisect
def searchRange(nums, target):
left = bisect.bisect_left(nums, target)
if left == len(nums) or nums[left] != target:
return [-1, -1]
right = bisect.bisect_right(nums, target) - 1
return [left, right]
7.2 Java实现对比
Java的实现更注重类型安全和性能:
java复制public int[] searchRange(int[] nums, int target) {
int[] result = {-1, -1};
if (nums == null || nums.length == 0) return result;
result[0] = findFirst(nums, target);
if (result[0] == -1) return result;
result[1] = findLast(nums, target);
return result;
}
private int findFirst(int[] nums, int target) {
int idx = -1;
int left = 0;
int right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid - 1;
} else {
left = mid + 1;
}
if (nums[mid] == target) idx = mid;
}
return idx;
}
7.3 C++实现对比
C++的实现可以利用STL中的算法:
cpp复制vector<int> searchRange(vector<int>& nums, int target) {
auto low = lower_bound(nums.begin(), nums.end(), target);
if (low == nums.end() || *low != target)
return {-1, -1};
auto high = upper_bound(nums.begin(), nums.end(), target);
return {static_cast<int>(low - nums.begin()), static_cast<int>(high - nums.begin() - 1)};
}
这些不同语言的实现展示了同一算法的不同表达方式,但核心思想都是相同的二分查找变种。
8. 进阶练习与学习资源
为了巩固对这个算法的理解,我推荐以下进阶练习:
- Leetcode 35. 搜索插入位置
- Leetcode 278. 第一个错误的版本
- Leetcode 162. 寻找峰值
- Leetcode 153. 寻找旋转排序数组中的最小值
此外,以下学习资源也很有帮助:
- 《算法导论》中的二分查找章节
- Leetcode讨论区的高赞题解
- 可视化算法学习网站(如visualgo.net)
- JavaScript算法相关的开源项目
在实际编码练习中,建议先自己尝试实现,再参考优秀解决方案,比较思路差异,这样才能真正掌握算法的精髓。
