LeetCode 33:搜索旋转排序数组——Java 两阶段二分查找详解

一、题目描述

给定一个元素互不相同、原本按升序排列的整数数组 nums。数组在传入函数前,可能在某个未知下标处发生了旋转。

例如,原数组:

复制代码
[0,1,2,4,5,6,7]

在下标 3 处旋转后,可能变成:

复制代码
[4,5,6,7,0,1,2]

再给定一个目标值 target,如果它存在于数组中,则返回对应下标;否则返回 -1。题目要求时间复杂度为 O(log n)。

普通升序数组可以直接二分查找,但旋转后的数组不再整体有序,因此不能直接套用标准模板。截图中的解决方案分为两步:先找到最小值下标,将数组划分成两个升序区间;再确定目标值属于哪一段,并执行普通二分查找。

二、旋转数组的结构

观察数组:

复制代码
[4,5,6,7,0,1,2]

虽然它整体不是升序,但可以按照最小值 0 的位置拆成两段:

复制代码
[4,5,6,7]  [0,1,2]

这两个子区间内部都保持升序。因此,只要先找到最小值所在下标 minIndex,就能得到两个可进行二分查找的区间:

复制代码
[0, minIndex - 1]
[minIndex, nums.length - 1]

所以本题可以拆成两个熟悉的问题:

  1. 二分查找旋转数组中的最小值;

  2. 在确定的升序区间中二分查找 target。

三、第一步:找到最小值下标

使用 left 和 right 表示当前搜索范围,每次取中间位置 mid,并将 nums[mid] 与 nums[right] 比较。

1. nums[mid] > nums[right]

复制代码
if (nums[mid] > nums[right]) {
    left = mid + 1;
}

这说明 mid 位于旋转点左侧的较大升序段中,最小值必然在 mid 的右边,因此可以排除 [left,mid]。

例如:

复制代码
[4,5,6,7,0,1,2]
       ↑       ↑
      mid    right

此时 7 > 2,最小值一定在右半部分。

2. nums[mid] < nums[right]

复制代码
else if (nums[mid] < nums[right]) {
    right = mid;
}

这说明 mid 位于包含最小值的右侧升序段中,最小值可能正是 nums[mid],也可能在它左边,所以不能排除 mid,应令 right = mid。

3. nums[mid] == nums[right]

复制代码
else {
    right--;
}

本题已说明数组元素互不相同,因此正常情况下不会进入这个分支。截图保留 right--,可以兼容数组存在重复元素的扩展场景:相等时无法判断最小值在哪一侧,只排除最右侧的一个重复元素。

循环条件使用:

复制代码
while (left < right)

当 left == right 时,搜索范围中只剩一个位置,该位置就是最小值下标。

四、第二步:判断目标值属于哪一段

找到 minIndex 后,旋转数组已经被划分成两个升序区间。接下来需要判断 target 应该在哪一段查找。

令:

复制代码
int right = nums.length - 1;

由于 nums[right] 是第二个升序区间的最大值,可以将 target 与它比较。

1. nums[right] < target

目标值大于第二段中的最大值,所以不可能在第二段,只能在第一段:

复制代码
return searchBinary(nums, target, 0, minIndex - 1);

2. nums[right] > target

结合题目的旋转结构,目标值若存在,应在以最小值开头的第二段中查找:

复制代码
return searchBinary(nums, target, minIndex, right);

如果 target 比数组最小值还小,虽然也会进入第二段搜索,但普通二分最终会返回 -1。

3. nums[right] == target

最后一个元素正好就是目标值,可以直接返回:

复制代码
return right;

这种判断方式避免了额外比较 nums[0],并与截图中的实现保持一致。

五、第三步:在升序区间中二分查找

确定目标区间后,剩下的就是标准二分查找。

本文使用闭区间 [left,right],所以循环条件为:

复制代码
while (left <= right)

如果中间元素小于目标值,搜索右侧;如果中间元素大于目标值,搜索左侧;如果相等,直接返回 mid。

复制代码
if (nums[mid] < target) {
    left = mid + 1;
} else if (nums[mid] > target) {
    right = mid - 1;
} else {
    return mid;
}

区间耗尽仍未命中,说明目标值不存在,返回 -1。

六、完整 Java 代码

下面的代码严格采用截图中的"先找最小值,再选择区间,最后普通二分"的结构:

复制代码
class Solution {
    public int search(int[] nums, int target) {
        // 找到最小值下标,从而划分出两个升序区间
        int minIndex = findMin(nums);
        int right = nums.length - 1;

        // target 大于第二段最大值,只可能位于第一段
        if (nums[right] < target) {
            return searchBinary(nums, target, 0, minIndex - 1);
        } else if (nums[right] > target) {
            // target 若存在,应位于第二段
            return searchBinary(nums, target, minIndex, right);
        }

        // nums[right] == target
        return right;
    }

    // 在指定闭区间中执行标准二分查找
    private int searchBinary(int[] nums, int target, int left, int right) {
        while (left <= right) {
            int mid = left + ((right - left) >>> 1);

            if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            } else {
                return mid;
            }
        }

        return -1;
    }

    // 查找旋转数组中最小值所在的下标
    private int findMin(int[] nums) {
        int left = 0;
        int right = nums.length - 1;

        while (left < right) {
            int mid = left + ((right - left) >>> 1);

            if (nums[mid] > nums[right]) {
                // mid 位于左侧较大升序段
                left = mid + 1;
            } else if (nums[mid] < nums[right]) {
                // mid 位于包含最小值的升序段
                right = mid;
            } else {
                // 本题元素不重复;该分支用于兼容重复元素
                right--;
            }
        }

        return left;
    }
}

七、复杂度分析

findMin 使用二分查找定位最小值,时间复杂度为 O(log n);searchBinary 又执行一次二分查找,时间复杂度也是 O(log n)。

两者顺序执行:

复制代码
O(log n) + O(log n) = O(log n)

算法只使用常数个变量,因此空间复杂度为 O(1)。

需要注意:如果扩展到存在大量重复元素的数组,right-- 可能使 findMin 在最坏情况下退化为 O(n);但本题元素互不相同,不存在该问题。

八、常见错误

1. 找最小值时写成 right = mid - 1

当 nums[mid] < nums[right] 时,mid 本身可能就是最小值,所以必须保留它,写成 right = mid。

2. 使用错误的循环条件

findMin 的目标是让两个指针相遇,应使用 left < right;普通二分使用闭区间,需要使用 left <= right。

3. 第一段的右边界写成 minIndex

最小值属于第二段,第一段应为 [0,minIndex - 1]。否则两个区间会发生重叠。

4. 忘记处理未旋转数组

例如 [1,2,3,4] 的最小值下标是 0。此时第一段为 [0,-1],是空区间;若目标值大于 nums[right] 的情况不会出现,其余目标会进入第二段 [0,right],代码仍能正确运行。

九、总结

搜索旋转排序数组的关键,是认识到旋转只破坏了整体有序性,却保留了两个局部升序区间。

本解法先通过 findMin 定位最小值下标,将数组划分为两段;再比较 target 与最后一个元素,选择可能包含目标值的升序区间;最后使用标准二分查找得到答案。

相关推荐
小羊没烦恼!3 天前
微服务化的基石——持续集成
java·大数据·word·powerpoint·.net
默_笙3 天前
🍙 给每个请求过安检:FastAPI 是怎么把校验写进类型注解的
python
俊昭喜喜里3 天前
java中的继承和多态的区别
java
小羊没烦恼!3 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
qq_426003963 天前
启动playwright录制codegen生成自动化测试脚本
python·自动化
譕痕3 天前
JSONObject与JSONArray封装数据格式区别
java·json
虎头金猫3 天前
4K 视频总卡在公网带宽?用 N1 + OpenList 把网盘播放链路重新理顺
运维·服务器·网络·python·容器·beautifulsoup·pandas
胡写代码3 天前
别再前后端各写一套表单校验了
java·后端
小鱼能吃糖3 天前
缺陷修复总览 · mall电商项目:5类缺陷,1个病根,4个业务域
java·电商
长沙三为智能科技3 天前
家政小程序开发从0到上线:五阶段交付流程与验收清单
python