力扣hot100之33:搜索旋转排序数组
力扣hot100之33:搜索旋转排序数组

LeetCode 33:搜索旋转排序数组(Search in Rotated Sorted Array)题解

Search in Rotated Sorted Array 是 LeetCode Hot 100 里面一道非常经典的二分查找题。

如果说普通二分查找考察的是“在有序数组中如何快速定位目标值”,那么这道题就是在它的基础上做了一层变化: 数组原本是升序排列的,但在某个位置发生了旋转。于是整个数组不再是完全有序,而是变成了:

局部有序、整体断开

这也是很多人第一次做这道题时最容易卡住的地方。因为一看到“旋转数组”,就会本能地觉得普通二分失效了,不知道还能不能继续用二分。

其实答案是: 仍然可以二分。

关键就在于:虽然整个数组不是单调递增的,但在每一次二分时,我们总能判断出 mid 左边或者右边至少有一半是有序的。只要利用这一点,就能继续缩小搜索范围。

你给出的这份 Java 代码,就是这道题非常经典的一种写法。 本文就结合这段代码,系统地讲一下这道题的思路。


题目介绍

给定一个经过旋转的升序数组 nums,以及一个目标值 target,要求你在数组中找到目标值的下标。

如果目标值不存在,就返回:

-1

题目中所说的“旋转”,意思是原本有序的数组在某个位置断开,然后把前半部分接到后面。

例如:

原数组:

[0,1,2,4,5,6,7]

如果在下标 3 的位置旋转后,就可能变成:

[4,5,6,7,0,1,2]

虽然这个数组整体上已经不是严格升序,但它依然保留了“两个局部有序区间”的特征。

题目要求你设计一个时间复杂度为:

O(log n)

的算法。

这也就意味着,暴力遍历肯定不是题目想考察的方法,核心一定是 二分查找


示例

示例 1

输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4

目标值 0 在数组中的下标是 4,所以返回 4

示例 2

输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1

数组中不存在 3,所以返回 -1

示例 3

输入:nums = [1], target = 0
输出:-1

只有一个元素,而且不是目标值,所以返回 -1


这道题为什么还能用二分?

很多人第一次看到旋转数组时,会觉得:

数组已经不是完全有序的了,那普通二分查找不就失效了吗?

确实,普通二分查找依赖的是“整个区间有序”。 而这道题里,整个数组不再满足这个条件。

但是这道题的关键在于:

每次取中点以后,左右两边一定至少有一边是有序的。

比如:

[4,5,6,7,0,1,2]

如果当前 mid 落在 7 上,那么:

  • 左半部分 [4,5,6,7] 是有序的

  • 右半部分 [0,1,2] 虽然也是有序的,但相对整体来说发生了断开

也就是说,即使整个数组不完全有序,我们依然能通过判断 nums[0]nums[mid]nums[n-1] 的关系,确定当前哪一半是单调有序的。

而一旦某一半是有序的,我们就能判断目标值是否落在这一半里面,从而决定下一步往左找还是往右找。

这就是这道题能继续使用二分查找的根本原因。


核心思路

你这段代码的核心逻辑可以概括成下面几步:

  1. 先正常二分,找到中点 mid

  2. 如果 nums[mid] == target,直接返回

  3. 判断当前是左半部分有序,还是右半部分有序

  4. 根据目标值是否落在有序区间中,决定缩小哪一边

重点就在第 3 步和第 4 步。


如何判断哪一半有序?

假设当前区间是 [lef, rig],中点是 mid

你的代码使用了下面这个判断:

if (nums[0] <= nums[mid]) {
    ...
} else {
    ...
}

这里的含义是:

  • 如果 nums[0] <= nums[mid],说明从数组起点到 mid 这一段是有序的

  • 否则,说明 mid 落在旋转点右侧,那么右半部分一定是有序的

在很多题解里,也有人写成:

if (nums[lef] <= nums[mid])

你这份代码写成 nums[0] 也是可以的,因为搜索过程中旋转结构本质没变,而且题目保证数组中元素互不相同。


Java 代码

下面就是你给出的代码,我这里保留原始逻辑,并加上注释:

class Solution {
    public int search(int[] nums, int target) {
        int siz = nums.length;
        int lef = 0, rig = siz - 1;

        while (lef <= rig) {
            int mid = (lef + rig) >> 1;

            // 如果刚好找到目标值,直接返回
            if (nums[mid] == target) return mid;

            // 判断左半部分是否有序
            if (nums[0] <= nums[mid]) {
                // 如果目标值落在左侧有序区间中,就去左边找
                if (nums[0] <= target && target < nums[mid]) rig = mid - 1;
                else lef = mid + 1;
            } else {
                // 否则说明右半部分有序
                // 如果目标值落在右侧有序区间中,就去右边找
                if (nums[mid] < target && target <= nums[siz - 1]) lef = mid + 1;
                else rig = mid - 1;
            }
        }

        return -1;
    }
}

代码拆解

这段代码整体不长,但逻辑绕的地方主要在“如何判断哪一边有序”以及“目标值应该落在哪一边”。 下面一步一步来看。


1. 为什么一开始还是普通二分模板?

int lef = 0, rig = siz - 1;
while (lef <= rig) {
    int mid = (lef + rig) >> 1;
    ...
}

因为这道题本质上还是查找问题,只不过数组不再完全有序。

所以整体框架依然是标准的二分模板:

  • 维护左右边界 lefrig

  • 取中点 mid

  • 每次排除一半区间

这说明:

旋转数组并没有让二分失效,它只是改变了“如何判断下一步去哪边找”的条件。


2. 为什么先判断 nums[mid] == target

if (nums[mid] == target) return mid;

这是任何查找题里最优先的判断。

因为如果中点就是目标值,那就没必要继续缩小区间了,直接返回即可。

这一句虽然简单,但它保证了后面的逻辑都是在“当前中点不是目标值”的前提下展开的。


3. 为什么 nums[0] <= nums[mid] 可以判断左边有序?

if (nums[0] <= nums[mid]) {
    ...
}

这是整道题最关键的判断之一。

在旋转排序数组中,数组可以看成两段:

  • 一段较大的有序部分

  • 一段较小的有序部分

例如:

[4,5,6,7,0,1,2]

这里:

  • [4,5,6,7] 是一段

  • [0,1,2] 是另一段

如果当前 mid 落在左边那一大段中,那么它一定满足:

nums[0] <= nums[mid]

因为左侧这段是从数组起点开始连续递增的。

反过来,如果 nums[0] > nums[mid],说明 mid 已经落在旋转点右边那一段小值区间里了,这时候右半部分一定有序。

所以这个判断本质上是在判断:

中点到底落在旋转点左边,还是右边。


4. 左边有序时,为什么用这个条件判断目标是否在左边?

if (nums[0] <= target && target < nums[mid]) rig = mid - 1;
else lef = mid + 1;

当左半部分有序时,它的值域范围就是:

[nums[0], nums[mid])

注意不包含 nums[mid],因为前面已经判断过 nums[mid] != target 了。

所以如果目标值满足:

  • 大于等于左端点 nums[0]

  • 小于中点值 nums[mid]

就说明目标值一定落在左边这个有序区间中。 此时就应该把搜索范围缩到左边:

rig = mid - 1;

否则,目标值不在左边,只能去右边找:

lef = mid + 1;

5. 右边有序时,为什么用这个条件判断目标是否在右边?

if (nums[mid] < target && target <= nums[siz - 1]) lef = mid + 1;
else rig = mid - 1;

如果左边不是有序的,那说明右边一定有序。

这时右边有序区间的值域范围就是:

(nums[mid], nums[siz - 1]]

因为 nums[mid] != target,所以中点值本身不需要包含。

如果目标值满足:

  • 大于 nums[mid]

  • 小于等于数组最后一个值 nums[siz - 1]

那就说明目标值一定在右边,于是移动左边界:

lef = mid + 1;

否则,就只能去左边继续找:

rig = mid - 1;

手动模拟一遍

我们用经典样例来走一遍:

nums = [4,5,6,7,0,1,2], target = 0

第一次

  • lef = 0

  • rig = 6

  • mid = 3

  • nums[mid] = 7

判断:

  • nums[mid] != target

  • nums[0] <= nums[mid],即 4 <= 7,成立

说明左半部分 [4,5,6,7] 有序。

再看目标值 0 是否在左边有序区间中:

  • 4 <= 0 不成立

所以目标值不在左边,去右边找:

lef = mid + 1 = 4

第二次

  • lef = 4

  • rig = 6

  • mid = 5

  • nums[mid] = 1

判断:

  • nums[mid] != target

  • nums[0] <= nums[mid],即 4 <= 1,不成立

说明右半部分有序。

判断目标值 0 是否在右边有序区间:

  • 1 < 0 不成立

所以目标值不在右边,只能去左边找:

rig = mid - 1 = 4

第三次

  • lef = 4

  • rig = 4

  • mid = 4

  • nums[mid] = 0

此时:

nums[mid] == target

直接返回 4

这正好就是答案。


再看一个找不到的例子

比如:

nums = [4,5,6,7,0,1,2], target = 3

通过不断二分后会发现:

  • 目标值既不在左边有序区间

  • 也不在右边有序区间

最终区间缩小到空,循环结束,返回:

-1

这表示数组中不存在目标值。


为什么这个算法是正确的?

因为在任意一次循环中,虽然整个区间不一定有序,但总能保证:

  • 左半边有序,或者

  • 右半边有序

而一旦知道某一半有序,我们就能判断目标值是否落在这半边中。

如果在,就保留这半边; 如果不在,就排除这半边。

这和普通二分类似,本质上仍然是在每一步排除掉一半不可能的区间,所以最终时间复杂度仍然是:

O(log n)

这也是这道题最核心的思想。


复杂度分析

时间复杂度

每次循环都会把搜索区间缩小一半,因此时间复杂度是:

O(log n)

空间复杂度

只使用了常数级别的额外变量,所以空间复杂度是:

O(1)

这道题真正想考什么?

这道题表面上是在考“旋转数组查找”,但本质上考察的是两个能力。

第一,是否真正理解二分查找

很多人把二分查找理解成“数组有序才能用”。 但更准确地说,二分查找真正依赖的是:

每一步都能判断出哪一半可以被排除。

这道题虽然整体不是完全有序,但依然满足“每次至少有一半有序”这个特征,所以仍然可以二分。

第二,是否能利用数组的结构特征

旋转数组虽然整体断开了,但它不是完全无序,而是保留了非常强的结构性:

  • 左边某段有序

  • 右边某段有序

  • 中点一定落在其中一段中

谁能抓住这个结构,谁就能把题做出来。


和普通二分查找有什么区别?

普通二分查找中:

  • 整个区间始终有序

  • 所以只要比较 targetnums[mid] 大小,就能决定往哪边走

而这道题中:

  • 整个区间不一定有序

  • 所以不能只看 targetnums[mid]

  • 必须先判断哪一半有序,再判断目标值是否在那一半中

也就是说,这题本质上是:

在普通二分上多加了一步“识别有序区间”的判断。

这是它和基础二分最大的区别。


总结

这道题的核心思想可以概括成一句话:

虽然旋转数组整体无序,但每次二分时总能找到一半有序区间,再根据目标值是否落在该区间中决定搜索方向。

具体步骤就是:

  1. 取中点 mid

  2. 如果 nums[mid] == target,直接返回

  3. 判断左半边还是右半边有序

  4. 再判断目标值是否在有序区间里

  5. 排除掉不可能的一半,继续二分

最终就能在:

O(log n)

的时间复杂度内完成查找。

这是一道非常经典、也非常值得反复理解的二分题。 因为它会让你真正意识到:二分查找并不只是“在完全有序数组里查数”,而是一种“利用结构特征不断缩小搜索范围”的思维方式。

把这道题真正吃透之后,再去看:

  • 寻找旋转排序数组中的最小值

  • 搜索旋转排序数组 II

  • 查找峰值

  • 寻找重复数

这些“变形二分”题目时,你会明显更容易找到切入点。

因为“搜索旋转排序数组”,本身就是二分查找进阶题里最经典的一道模板题。

如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇