一、题目描述
给定一个元素互不相同、原本按升序排列的整数数组 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]
所以本题可以拆成两个熟悉的问题:
-
二分查找旋转数组中的最小值;
-
在确定的升序区间中二分查找
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 与最后一个元素,选择可能包含目标值的升序区间;最后使用标准二分查找得到答案。