一次搞懂「找第一个位置」和「找最后一个位置」的二分写法
写在前面
大家好,今天我们来聊聊 LeetCode 上一道非常经典的二分查找题目 ------ 34. 在排序数组中查找元素的第一个和最后一个位置。
这道题看起来很简单:给定一个排序好的数组和一个目标值,找到目标值出现的起始下标和结束下标。但题目有一个硬性要求:时间复杂度必须是 O(log n) 。
这意味着什么?意味着你不能用简单的遍历(O(n) 的解法)。必须用二分查找(Binary Search)来解决。而二分查找本身只能找到一个元素,现在我们要找的是一段连续区间的左右边界。这就需要对二分查找做一些巧妙的改造。
本文会一步一步带着大家搞懂:
- 二分查找的基本原理
- 如何用二分查找找左边界(第一个等于 target 的位置)
- 如何用二分查找找右边界(最后一个等于 target 的位置)
- 边界条件和细节处理
- 完整的代码实现
废话不多说,我们直接开始。
问题回顾
题目给了一个非递减顺序排列 的整数数组 nums,和一个目标值 target。你需要返回 target 在数组中的开始位置和结束位置。如果不存在,返回 [-1, -1]。
示例:
text
ini
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
输入:nums = [], target = 0
输出:[-1,-1]
注意:非递减顺序意味着元素可以相等,也就是可以有重复值。这正是我们需要找边界的原因------同一个目标值可能出现多次。
初识二分查找
在讲边界查找之前,我们先快速回顾一下标准的二分查找是做什么的。
二分查找适用于有序数组。它的核心思想是:每次取数组中间的元素,与目标值比较:
- 如果中间元素等于目标值,就找到了,返回下标。
- 如果中间元素小于目标值,说明目标值在右半边,我们就把左边界移到
mid + 1。 - 如果中间元素大于目标值,说明目标值在左半边,我们就把右边界移到
mid - 1。
重复这个过程,直到左边界超过右边界,说明没找到。
javascript
ini
// 标准二分查找,返回任意一个等于 target 的下标,不存在返回 -1
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;
}
这个写法很好理解。但是,如果数组中有多个相同的 target,它只会返回某一个(通常是中间碰到的那个),无法保证是第一个还是最后一个。而题目要求我们返回第一个和最后一个的位置,所以我们需要对二分查找进行改造。
思路:两次二分,分别找左边界和右边界
既然一次二分只能找到一个位置,那我们就分开两次:
- 找左边界 :找到第一个等于
target的位置。 - 找右边界 :找到最后一个等于
target的位置。
两次都使用二分查找,这样总的时间复杂度依然是 O(log n)(因为 2 * log n 还是 log n 级别的)。
关键在于,如何修改二分查找的逻辑,使得它能够找到边界?
找左边界(第一个位置)
目标 :在 nums 中找到第一个 nums[i] == target 的 i。如果不存在,返回 -1。
思路
我们依然使用二分查找,但当我们 nums[mid] == target 时,不立即返回 mid ,因为我们不能确定 mid 是不是第一个。例如 [5,7,8,8,8,10],target=8,mid 可能指向第二个 8(下标 3),但第一个 8 在下标 2。所以我们需要继续往左半边查找,看看还有没有更靠前的 target。
具体做法:
- 当
nums[mid] == target时,记录当前位置result = mid,然后将right移到mid - 1,继续在左半边搜索。 - 当
nums[mid] < target时,说明 target 在右边,left = mid + 1。 - 当
nums[mid] > target时,说明 target 在左边,right = mid - 1。
最后,result 中保存的就是最后一个被记录的等于 target 的位置,也就是最左边的那个(因为每次找到相等就向左收缩,最终会停在最左边界)。
代码实现
javascript
ini
function findLeft(nums, target) {
let left = 0;
let right = nums.length - 1;
let result = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
result = mid; // 记录当前找到的位置
right = mid - 1; // 继续向左找,看有没有更左边的
} else if (nums[mid] < target) {
left = mid + 1;
} else { // nums[mid] > target
right = mid - 1;
}
}
return result;
}
举个栗子 :nums = [5,7,7,8,8,10],target = 8。
- 初始 left=0, right=5, mid=2(值为7) <8 → left=3
- left=3,right=5,mid=4(值为8) ==8 → result=4, right=3
- left=3,right=3,mid=3(值为8) ==8 → result=3, right=2
- left=3,right=2 循环结束,返回 result=3 ✅
完美找到了第一个 8 的位置 3。
找右边界(最后一个位置)
目标 :找到最后一个 nums[i] == target 的 i。如果不存在,返回 -1。
思路
和左边界对称。当 nums[mid] == target 时,我们不立即返回,而是继续向右半边查找 ,看看还有没有更靠后的 target。此时我们需要移动 left = mid + 1,并记录当前位置。
具体做法:
- 当
nums[mid] == target时,记录result = mid,然后left = mid + 1(向右找)。 - 当
nums[mid] < target时,left = mid + 1。 - 当
nums[mid] > target时,right = mid - 1。
注意:这里 nums[mid] < target 和等于的情况都是移动 left,所以可以合并成 if (nums[mid] <= target) 然后移动 left?但为了逻辑清晰,我们还是分开写。
代码实现
javascript
ini
function findRight(nums, target) {
let left = 0;
let right = nums.length - 1;
let result = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
result = mid; // 记录当前找到的位置
left = mid + 1; // 继续向右找,看有没有更右边的
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
继续上面的例子 :nums = [5,7,7,8,8,10],target = 8。
- left=0,right=5,mid=2(7) <8 → left=3
- left=3,right=5,mid=4(8) ==8 → result=4, left=5
- left=5,right=5,mid=5(10) >8 → right=4
- left=5,right=4 循环结束,返回 result=4 ✅
结果是最后一个 8 的位置 4。
完整代码
有了 findLeft 和 findRight,主函数就很简单了:分别调用,如果左边界是 -1(说明没找到),直接返回 [-1, -1];否则返回 [left, right]。
javascript
ini
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var searchRange = function(nums, target) {
// 找左边界
function findLeft() {
let left = 0;
let right = nums.length - 1;
let result = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
result = mid;
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
// 找右边界
function findRight() {
let left = 0;
let right = nums.length - 1;
let result = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
result = mid;
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return result;
}
const first = findLeft();
const last = findRight();
if (first === -1) {
return [-1, -1];
}
return [first, last];
};
边界情况与细节
1. 数组为空
nums.length === 0 时,left=0, right=-1,循环条件 left <= right 不成立,两个函数直接返回 -1。主函数检测到 first === -1,返回 [-1, -1]。完美。
2. 目标值不存在
例如 nums = [5,7,7,8,8,10], target=6。二分搜索过程中永远碰不到相等的情况,result 始终为 -1。最终返回 [-1, -1]。
3. 目标值只有一个
例如 nums = [5,7,8,10], target=8。左边界和右边界都会找到同一个下标,因为向左收缩和向右收缩都只会碰到这一个。返回 [2,2]。
4. 目标值在数组开头或结尾
例如 nums = [8,8,8,10], target=8。左边界会一直向左收缩直到 left > right,最终 result 停在 0;右边界会一直向右收缩,result 停在 2。没问题。
5. 大数溢出问题
虽然 JavaScript 中 (left + right) / 2 不会溢出(JS 的 Number 是浮点数),但在其他语言(如 Java、C++)中,(left + right) 可能超过 int 范围。通常建议写成 left + Math.floor((right - left) / 2)。为了通用性,我们可以也这么写:
javascript
sql
const mid = left + Math.floor((right - left) / 2);
这样更安全,也推荐大家养成这个习惯。
复杂度分析
- 时间复杂度:O(log n)。两次二分查找,每次都是对半分割,所以总操作次数约为 2 * log₂(n),常数系数忽略,即为 O(log n)。
- 空间复杂度:O(1)。只使用了几个变量,没有额外数组。
举一反三:另一种优雅写法
有些同学可能会想,有没有办法只写一个通用的 binarySearch 函数,通过传入参数来决定找左边界还是右边界?当然可以。但为了代码可读性,面试时分开写两个函数也没问题。不过我们可以简单了解一下统一写法:
javascript
ini
function binarySearch(nums, target, findLeft) {
let left = 0, right = nums.length - 1;
let result = -1;
while (left <= right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] === target) {
result = mid;
if (findLeft) {
right = mid - 1; // 向左找
} else {
left = mid + 1; // 向右找
}
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
然后用 binarySearch(nums, target, true) 找左边界,binarySearch(nums, target, false) 找右边界。这样代码更紧凑,但理解起来稍微绕一点。个人觉得分两个函数更直观。
总结
通过这道题,我们学会了一个重要的二分查找变种:寻找边界。
核心要点:
- 找左边界:遇到相等时不返回,继续向左收缩,记录位置。
- 找右边界:遇到相等时不返回,继续向右收缩,记录位置。
- 注意循环条件是
left <= right,这样能确保所有元素都被检查到。 - 处理空数组和不存在的元素。
二分查找看似简单,但细节很容易出错。建议大家多写几遍,尤其是边界条件的调整。掌握了这个思路,以后遇到"在排序数组中找第一个大于等于某值的元素"或者"找最后一个小于某值的元素"等问题,都可以迎刃而解。
如果你觉得这篇文章对你有帮助,不妨点个赞或者收藏一下。我们下期见!