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

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

使用 leftright 表示当前搜索范围,每次取中间位置 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 与最后一个元素,选择可能包含目标值的升序区间;最后使用标准二分查找得到答案。

相关推荐
二进制漫游记4 分钟前
FastAPI项目集成 Qdrant向量数据库+阿里云Embedding完整实战(工具封装+业务调用)
数据库·python·阿里云·embedding
Tisfy24 分钟前
LeetCode 3876.构造奇偶一致的数组 II:三种情况分类讨论(其实还是脑筋急转弯)
算法·leetcode·题解·脑筋急转弯
-今昭-1 小时前
《V2V 迁移实战:将 VMware 虚拟机转为 OpenStack 可用 Glance qcow2 镜像》
开发语言·python
2601_962381581 小时前
【转】Python渗透测试工具:sqlmap
python·渗透测试·sql注入·sqlmap·数据库安全
zww89491111 小时前
匿名树洞系统开发实战:从需求分析到部署指南
java·eclipse
用户3721574261352 小时前
如何使用 Python 从 Word 文档中提取图片
python
黑马程序员毕设3 小时前
基于Java的医院药品管理系统的优化设计与实现
java·开发语言·spring boot·小程序·架构·课程设计·毕设
木井巳3 小时前
【BFS/DFS 解决 FloodFill 算法】太平洋大西洋水流问题
java·算法·leetcode·深度优先·广度优先·宽度优先·推荐算法
白山编程大哥3 小时前
Java 集合算法:从排序、查找到底层原理的实战指南
java·python·算法
devpotato3 小时前
缓存与数据库更新顺序不一致问题
java·数据库·redis