LeetCode-33.搜索旋转排序数组-二分查找
LeetCode-33.搜索旋转排序数组
整数数组 nums
按升序排列,数组中的值 互不相同 。
在传递给函数之前,nums
在预先未知的某个下标 k
(0 <= k < nums.length
)上进行了 向左旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
(下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7]
下标 3
上向左旋转后可能变为 [4,5,6,7,0,1,2]
。
给你 旋转后 的数组 nums
和一个整数 target
,如果 nums
中存在这个目标值 target
,则返回它的下标,否则返回 -1
。
你必须设计一个时间复杂度为 O(log n)
的算法解决此问题。
示例 1:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4
示例 2:
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1
示例 3:
输入:nums = [1], target = 0
输出:-1
分析:
题干描述的是一个经过向左旋转的升序整数数组(原数组元素互不相同,旋转后分为两段各自升序的子数组,如 [4,5,6,7,0,1,2]),需要在数组中查找目标值 target,找到则返回其下标,未找到返回 - 1,且要求算法时间复杂度为 O (log n),这意味着必须采用二分查找的思路,因为只有二分能达到该时间复杂度。
二分查找下界查找模版
while(l<=r){int mid=(r+l)/2;if(nums[mid]>=target){r=mid-1;ans=mid;}else{l=mid+1;}
}
首先看循环条件 while(l <= r)
,这个条件决定了循环会覆盖数组中所有可能的元素位置 —— 只要左指针 l
没有超过右指针 r
,就意味着当前范围内仍有未检查的元素,需要继续二分。
接着分析内部的核心分支:当 nums[mid] >= target
时,说明当前 mid
位置的元素满足 “大于等于 target” 的条件,它有可能是我们要找的 “第一个符合条件的元素”,但也可能存在更靠左的元素同样满足条件(比如数组中有多个大于等于 target 的元素,我们需要最左侧的那个)。因此,先将 ans
暂存为 mid
(记录当前找到的符合条件的位置),再把右指针 r
移到 mid - 1
—— 这一步是关键的边界收缩,目的是 “放弃当前 mid
右侧的所有元素”,因为右侧元素即使符合条件,下标也比 mid
大,不可能是 “第一个”,所以收缩右边界,在左半部分继续查找是否有更靠前的符合条件的位置。
再看 else 分支(即 nums[mid] < target
),此时 mid
位置的元素不满足 “大于等于 target” 的条件,说明所有在 mid
左侧的元素(比 mid
位置元素更小)也必然不满足条件(假设数组是升序的,这是二分查找的前提),因此无需检查左半部分,直接将左指针 l
移到 mid + 1
—— 这一步是通过收缩左边界,将查找范围定位到 mid
右侧,去寻找更大的元素,直到找到第一个大于等于 target 的位置。
整个过程中,边界处理的核心逻辑是 “符合条件则向左收缩找更优解,不符合条件则向右收缩找可能解”:通过 r = mid - 1
不断压缩右边界,确保每次暂存的 ans
都是当前范围内最靠左的符合条件的下标;通过 l = mid + 1
不断压缩左边界,排除所有不可能符合条件的小元素。当循环结束时(l > r
),ans
就会保留住 “第一个大于等于 target 的元素下标”,如果数组中所有元素都小于 target,ans
会是初始值(通常需提前初始化,如 ans = -1
或 ans = n
,具体视场景而定),这也是边界处理的隐性兜底。
二分查找上界查找
while(l<=r){int mid=(r+l)/2;if(nums[mid]>target){r=mid-1;ans=mid;}else{l=mid+1;}
}
下界查找的目标是 “捕获所有≥target 的元素中最靠左的那个”,而当前模版的目标是 “捕获所有>target 的元素中最靠左的那个”,也就是常说的 “上界查找”(找第一个严格大于 target 的元素下标)。
先看分支判断的核心区别:下界查找中,只要nums[mid] ≥ target
就会触发 ans 记录和左移 r,因为 “等于 target” 的元素属于下界查找的目标范畴 —— 即使 mid 位置元素等于 target,也可能存在更靠左的等于 target 的元素,所以需要继续向左收缩范围。但当前模版的分支判断是nums[mid] > target
,只有当元素严格大于target 时才会记录 ans 并左移 r,这是因为 “等于 target” 的元素不符合当前模版 “找严格大于” 的目标,此时必须向右收缩范围(else 分支l=mid+1
),跳过所有等于 target 的元素,直到找到第一个真正大于 target 的位置。比如数组[1,3,5,7]
,target=5:下界查找会在nums[mid]=5
(mid=2)时记录 ans 并左移 r,最终返回 2(第一个≥5 的下标);而当前模版遇到nums[mid]=5
时,会进入 else 分支,l=mid+1=3,继续查找,直到 mid=3(nums [3]=7>5)时记录 ans,最终返回 3(第一个>5 的下标)。
要分析改题目就要抓住旋转数组的核心特性:升序且无重复的数组旋转后,会分裂成两段独立的升序子数组(比如 [4,5,6,7,0,1,2],左段 [4,5,6,7] 和右段 [0,1,2] 均升序,且左段所有元素大于右段)。二分查找的关键是 “利用有序性缩小范围”,这里的核心思路就是:每次二分后,先判断mid
所在的子数组是否有序,再将target
与有序子数组的边界对比,确定target
可能存在的区间,进而收缩左右指针 —— 这本质是把 “部分有序” 的旋转数组,转化为每次二分后的 “单一有序区间”,再套用类似基础二分的边界处理逻辑。
算法的执行过程中,先初始化闭区间[l, r]
(l=0
,r=n-1
),循环条件while(l<=r)
确保覆盖所有可能的元素位置,这和基础二分模版的闭区间处理逻辑一致(避免漏查)。每次计算mid
后,首先检查nums[mid]
是否直接等于target
,若命中则直接返回下标,这是所有二分查找的 “快速出口”,减少不必要的判断。
若未命中,则进入核心的 “有序区间判断与边界收缩” 环节:第一步是确定mid
所在的子数组是否有序 —— 由于旋转数组的特性,mid
要么落在左段有序子数组(满足nums[0]<=nums[mid]
,因为左段最小元素是nums[0]
,且左段所有元素大于右段),要么落在右段有序子数组(nums[0]>nums[mid]
时,右段必有序,因为左段无序则右段一定保持升序)。这一步是将旋转数组的 “部分有序” 转化为 “单一有序区间” 的关键,为后续套用基础二分逻辑铺路。
当mid
在左段有序子数组(nums[0]<=nums[mid]
)时,接下来判断target
是否在左段的有序范围内:若target
大于等于左段起点nums[0]
,且小于nums[mid]
,说明target
只可能在左段有序区间内(因为左段有序,不在这个范围的话,左段其他位置也不可能有target
),此时按基础二分逻辑收缩右边界r=mid-1
,聚焦左段查找;若target
不在这个范围,说明target
只能在右段无序区间,于是收缩左边界l=mid+1
,转向右段查找。
当mid
在右段有序子数组(nums[0]>nums[mid]
)时,判断逻辑类似:若target
大于nums[mid]
,且小于等于右段终点nums[n-1]
(右段最大元素是nums[n-1]
),说明target
在右段有序区间内,按基础二分逻辑收缩左边界l=mid+1
,聚焦右段查找;若target
不在这个范围,说明target
在左段无序区间,收缩右边界r=mid-1
,转向左段查找。
class Solution {public int search(int[] nums, int target) {int n = nums.length;int l=0,r=n-1;while(l<=r){int mid=(l+r)/2;if(nums[mid]==target){return mid;}if(nums[0]<=nums[mid]){if(nums[0]<=target&&nums[mid]>target){r=mid-1;}else{l=mid+1;}}else{if(nums[mid]<target&&nums[n-1]>=target){l=mid+1;}else{r=mid-1;}}}return -1;}
}