一、题目与核心考点
题目要求
整数数组 nums 原本是按升序排列的,且数组中的元素互不重复。在预先未知的某个下标上进行了一次旋转,例如 [0,1,2,4,5,6,7] 在下标 3 处旋转后变为 [4,5,6,7,0,1,2]。 请你在旋转后的数组中搜索目标值 target,如果数组中存在目标值则返回它的下标,否则返回 -1。要求算法时间复杂度必须是 O (log n) 级别。
核心考点
这道题的本质是有序数组变形后的二分查找,核心考察三个能力:
- 抓住旋转数组的局部有序性质,将问题向普通二分靠拢
- 边界条件与等号的精准控制,避免临界场景出错
- 区间收缩逻辑的严谨性,保证 O (log n) 的时间效率
最优解时间复杂度 O (log n),空间复杂度 O (1),是原地二分的标准空间开销。
二、思路完整推导:从性质到解法
1. 旋转数组的本质结构
原本严格升序的数组,在某一点旋转一次后,会变成两段连续升序、中间一个断崖下降点 的结构。 例如 [4,5,6,7,0,1,2]:
- 前半块:
[4,5,6,7]严格升序 - 后半块:
[7,0,1,2]无序
且整个数组只有这一个下降点,旋转点的位置是完全未知的 ------ 这正是题目的考点:不能先遍历找旋转点(O (n) 不符合要求),必须直接在原数组上实现 O (log n) 查找。
2. 核心铁律:mid 切分,必有一段有序
这是解题的根本,也是所有逻辑的出发点:
任取一个中间位置 mid,将当前搜索区间切分为左右两半,左右两段必有且至少有一段是完全升序的。
为什么这个性质永远成立? 因为整个数组只有一个旋转断崖,mid 这一刀最多只能跨过这一个断崖:
- 如果 mid 在断崖左侧 → 左半段完整升序,右半段跨断崖、整体无序
- 如果 mid 在断崖右侧 → 右半段完整升序,左半段跨断崖、整体无序
- 如果搜索区间已经缩小到单个有序块内 → 两段都有序,不影响判断
无论哪种情况,我们永远能找到一段确定有序的区间,用它来判断目标位置,每次排除一半元素。
3. 解法逻辑:盯着有序段做二分
普通二分查找的前提是「整个区间有序」,所以直接和中点比大小就能缩小区间;但旋转数组整体无序,不能直接用中点判断左右。
我们的解法就是把参照物从「中点一个值」换成「有序段的两个端点」:
- 先判断左、右哪半段是有序的
- 利用有序段的左右端点,判断 target 是否落在这个有序区间内
- 在区间内就收缩到这半段,不在就去另一半
- 循环往复,直到命中目标或区间为空
全程不需要知道旋转点在哪,靠局部有序性就能稳定收缩区间,依然保持 O (log n) 的效率。
三、最终定稿:标准单遍二分 AC 代码
java
class Solution {
public int search(int[] nums, int target) {
// 边界判空
if (nums == null || nums.length == 0) {
return -1;
}
int left = 0;
int right = nums.length - 1;
// 左闭右闭区间二分
while (left <= right) {
// 计算中点,防止大数溢出
int mid = left + (right - left) / 2;
// 命中目标,直接返回下标
if (nums[mid] == target) {
return mid;
}
// 判断左半段 [left, mid] 是否有序
if (nums[left] <= nums[mid]) {
// 左半段有序:判断 target 是否在左半区间内
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1; // 在左半,收缩右边界
} else {
left = mid + 1; // 不在左半,去右半找
}
} else {
// 右半段 [mid, right] 有序:判断 target 是否在右半区间内
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1; // 在右半,收缩左边界
} else {
right = mid - 1; // 不在右半,去左半找
}
}
}
// 循环结束未找到
return -1;
}
}
四、逐行代码深度拆解
1. 边界与初始化
java
if (nums == null || nums.length == 0) return -1;
int left = 0;
int right = nums.length - 1;
- 空数组直接返回 -1,防御式编程,避免后续下标访问异常
- 采用左闭右闭 区间
[left, right],和常规二分写法一致,最容易理解和记忆
2. 循环与中点计算
java
while (left <= right) {
int mid = left + (right - left) / 2;
- 左闭右闭区间对应循环条件
left <= right:当left == right时,区间内仍有一个元素,需要参与判断 left + (right - left) / 2等价于(left + right) / 2,但可以避免两数之和超出 int 范围导致的整数溢出,是算法题通用标准写法
3. 中点命中直接返回
java
if (nums[mid] == target) return mid;
二分查找的常规操作,中点刚好是目标值时直接返回下标。
4. 判断左半段是否有序(最关键的一行)
java
if (nums[left] <= nums[mid]) {
这里必须带等号 <=,绝对不能只写 <,是新手最容易翻车的细节。
- 当区间收缩到只剩两个元素时,
left和mid下标会重合(如left=0, right=1→mid=0),此时左半段只有单个元素,天然有序 - 如果漏写等号,该场景会误判为「左半无序」,进入错误分支,导致目标值查找失败
- 经典反例:
[3,1]查找 1,漏写等号会直接返回 -1
5. 左半有序时的区间判断
java
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
- 左半段是升序的,因此 target 只要落在
[nums[left], nums[mid])区间内,就一定在左半段 - 区间左闭右开:因为
nums[mid]已经单独判断过不等于 target,可以直接排除 - 在区间内就收缩右边界,不在则说明目标在右半段,收缩左边界
6. 右半有序时的区间判断
java
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
- 左半段无序时,右半段必然有序,同理判断 target 是否在右半升序区间内
- 区间左开右闭,同样排除已判断过的
nums[mid]
五、新手必踩坑清单
坑 1:有序性判断漏写等号
- 现象:两元素边界场景(如
[3,1])查找失败,返回错误结果 - 根源:未覆盖
left与mid下标重合的临界情况 - 原则:判断有序段必须带等号,单个元素也是有序区间
坑 2:区间端点判断漏掉等号
- 现象:target 刚好在有序段端点时,判断错误,无法命中
- 根源:有序区间的边界开闭对应错误,端点被排除在外
- 原则:左端点判断带
>=,右端点判断对应带<=,与区间开闭保持一致
坑 3:边界收缩不加减 1
- 现象:死循环或漏掉元素
- 根源:
mid已经判断过不等于 target,不需要再保留在区间内 - 原则:左闭右闭体系下,收缩边界必须写
mid ± 1
坑 4:中点计算溢出
- 现象:数组长度极大时,
left + right超出 int 范围,结果为负 - 原则:永远使用
left + (right - left) / 2计算中点
六、拓展:两步法(先找旋转点,再二分)
如果觉得单遍法不好理解,可以采用更直观的两步法,本质是把旋转数组拆回两个有序数组,再做普通二分,时间复杂度同样是 O (log n)。
完整代码
java
class Solution {
public int search(int[] nums, int target) {
if (nums == null || nums.length == 0) return -1;
// 第一步:找到旋转点(数组最小值的下标)
int minIndex = findMinIndex(nums);
// 第二步:判断 target 在哪一段有序区间,执行普通二分
if (target >= nums[minIndex] && target <= nums[nums.length - 1]) {
return binarySearch(nums, minIndex, nums.length - 1, target);
} else {
return binarySearch(nums, 0, minIndex - 1, target);
}
}
// 普通二分查找
private int binarySearch(int[] nums, int left, int right, int target) {
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
// 二分查找旋转点(最小值下标)
private int findMinIndex(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) {
// 中点大于右端点,最小值在右半段
left = mid + 1;
} else {
// 中点小于右端点,最小值在左半段(含mid)
right = mid;
}
}
return left;
}
}
两种方法对比
表格
| 方法 | 优点 | 缺点 |
|---|---|---|
| 单遍二分法 | 代码简洁,一次循环完成 | 逻辑稍抽象,需要理解局部有序性 |
| 两步法 | 思路直观,贴合直觉 | 代码稍长,需要写两次二分逻辑 |
面试中单遍法是主流写法,更能体现对二分变形的理解深度。
七、面试相关
口述思路(直接背)
这道题我用二分查找来实现。旋转排序数组有一个核心性质:任意中点分割后,左右两半必有一段是完全升序的。
我维护左闭右闭的搜索区间,每次取中点,如果中点就是目标值直接返回。然后通过比较左端点和中点值,判断哪一半是有序的:如果左半段有序,就判断目标值是否在左半区间内,在就收缩右边界,不在就去右半段;右半段有序同理。每次循环排除一半元素,循环结束没找到就返回 -1。
时间复杂度 O (log n),空间复杂度 O (1)。
高频追问
- 为什么时间复杂度是 O (log n)? 每次循环都能排除一半的元素,搜索区间规模每次减半,和普通二分查找一致,因此时间复杂度为 O (log n)。
- 如果数组有重复元素怎么办? 对应 LeetCode 81 题。重复元素会导致
nums[left] == nums[mid] == nums[right]的场景,无法判断哪段有序,此时只能将左右指针各向内移动一位,最坏情况时间复杂度退化为 O (n)。 - 如何找到旋转点? 旋转点就是数组的最小值位置,通过二分比较中点和右端点的大小,每次排除一半区间,最终 left 和 right 收敛的位置就是最小值下标。
八、复习速记口诀
旋转二分不用慌,半边有序是脊梁; 先判哪边升序列,端点对比缩区间; 等号细节别漏掉,溢出中点要提防。
总结
搜索旋转排序数组是二分查找的经典变形,看似复杂,实则只是在普通二分的基础上,增加了「判断有序段」的步骤。 最核心的不是记住判断条件,而是理解「局部有序」的本质性质 ------ 正是因为永远有半边是确定升序的,我们才能在不知道旋转点的前提下,依然保持对数级的查找效率。 掌握这个思路后,同类型的寻找旋转最小值、搜索旋转数组 II 等题目,都可以快速迁移解法。