二分查找进阶:在排序数组中寻找元素的边界

一次搞懂「找第一个位置」和「找最后一个位置」的二分写法

写在前面

大家好,今天我们来聊聊 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,它只会返回某一个(通常是中间碰到的那个),无法保证是第一个还是最后一个。而题目要求我们返回第一个和最后一个的位置,所以我们需要对二分查找进行改造。


思路:两次二分,分别找左边界和右边界

既然一次二分只能找到一个位置,那我们就分开两次:

  1. 找左边界 :找到第一个等于 target 的位置。
  2. 找右边界 :找到最后一个等于 target 的位置。

两次都使用二分查找,这样总的时间复杂度依然是 O(log n)(因为 2 * log n 还是 log n 级别的)。

关键在于,如何修改二分查找的逻辑,使得它能够找到边界?


找左边界(第一个位置)

目标 :在 nums 中找到第一个 nums[i] == targeti。如果不存在,返回 -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] == targeti。如果不存在,返回 -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。


完整代码

有了 findLeftfindRight,主函数就很简单了:分别调用,如果左边界是 -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,这样能确保所有元素都被检查到。
  • 处理空数组和不存在的元素。

二分查找看似简单,但细节很容易出错。建议大家多写几遍,尤其是边界条件的调整。掌握了这个思路,以后遇到"在排序数组中找第一个大于等于某值的元素"或者"找最后一个小于某值的元素"等问题,都可以迎刃而解。

如果你觉得这篇文章对你有帮助,不妨点个赞或者收藏一下。我们下期见!

相关推荐
昭昭颂桉a1 小时前
TypeScript 前端的必修课,从 JS 到 TS
开发语言·前端·javascript·typescript
用户938515635071 小时前
从零实现一个 Todos 应用:原生 Ajax + Node 服务,顺便吃透 JSON.stringify
前端·javascript·后端
codeking1 小时前
3 步把 AI 桌面自动化从失控拉回可用
javascript·架构
代码不加糖2 小时前
MessageChannel是什么,有什么使用场景?
前端·javascript
人无远虑必有近忧!2 小时前
fetch请求图片报跨域
前端·javascript
chushiyunen3 小时前
vue export default
前端·javascript·vue.js
zzqssliu3 小时前
Next.js图片自适应压缩:跨境站点图片加载提速代码方案
linux·javascript·ubuntu
北极星日淘4 小时前
可买免税店货物与安耐晒——特殊商品代购技术方案
javascript·vue.js·elementui
FirstFrost --sy4 小时前
基于高并发服务器的web小游戏测试
服务器·前端·javascript·c++·python·集成测试