Java版LeetCode热题100之搜索旋转排序数组:从原理到实战的深度剖析
本文将全面解析 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,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约束条件
1 <= nums.length <= 5000-10^4 <= nums[i] <= 10^4nums中的每个值都独一无二- 题目数据保证
nums在预先未知的某个下标上进行了旋转 -10^4 <= target <= 10^4
二、原题分析
这道题是二分查找的经典变种,其难点在于:
- 数组整体无序,但由两个升序子数组拼接而成;
- 旋转点未知,无法直接判断哪一段包含
target; - 必须满足 O(log n) 时间复杂度→ 排除线性扫描。
关键观察
原始数组是严格升序(无重复),旋转后形成“山峰”形结构:
- 前半段:
[nums[k], ..., nums[n-1]]升序; - 后半段:
[nums[0], ..., nums[k-1]]升序; - 且
nums[k] > nums[k-1](因为原数组升序)。
- 前半段:
重要性质:
将旋转数组从任意位置mid分成两部分[l, mid]和[mid+1, r],至少有一部分是严格升序的。
例如:[4,5,6,7,0,1,2]
- 若
mid=2(值为6),则[4,5,6]有序,[7,0,1,2]无序; - 若
mid=4(值为0),则[4,5,6,7,0]无序,[1,2]有序。
✅ 这一性质是解题的核心突破口!
三、答案构思
核心思路:改进的二分查找
虽然数组整体无序,但每次二分时,我们可以判断哪一半是有序的,然后利用有序性来决定搜索方向。
具体步骤:
- 计算
mid = (l + r) / 2; - 若
nums[mid] == target,直接返回; - 判断
[l, mid]是否有序(即nums[l] <= nums[mid]):- 如果是,则检查
target是否在[nums[l], nums[mid])区间内:- 是 → 搜索左半部分(
r = mid - 1) - 否 → 搜索右半部分(
l = mid + 1)
- 是 → 搜索左半部分(
- 如果是,则检查
- 否则,
[mid+1, r]必然有序,检查target是否在(nums[mid], nums[r]]区间内:- 是 → 搜索右半部分(
l = mid + 1) - 否 → 搜索左半部分(
r = mid - 1)
- 是 → 搜索右半部分(
💡 注意:由于数组无重复元素,
nums[l] <= nums[mid]可安全用于判断左半是否有序。
四、完整答案(Java 实现)
官方推荐解法
classSolution{publicintsearch(int[]nums,inttarget){intn=nums.length;if(n==0)return-1;if(n==1)returnnums[0]==target?0:-1;intl=0,r=n-1;while(l<=r){intmid=(l+r)/2;if(nums[mid]==target){returnmid;}// 判断左半部分 [l, mid] 是否有序if(nums[l]<=nums[mid]){// 左半有序if(nums[l]<=target&&target<nums[mid]){// target 在左半有序区间内r=mid-1;}else{// target 不在左半,去右半找l=mid+1;}}else{// 右半部分 [mid+1, r] 有序if(nums[mid]<target&&target<=nums[r]){// target 在右半有序区间内l=mid+1;}else{// target 不在右半,去左半找r=mid-1;}}}return-1;}}替代写法:使用left + (right - left) / 2防溢出
classSolution{publicintsearch(int[]nums,inttarget){intleft=0,right=nums.length-1;while(left<=right){intmid=left+(right-left)/2;if(nums[mid]==target){returnmid;}if(nums[left]<=nums[mid]){// 左侧有序if(target>=nums[left]&&target<nums[mid]){right=mid-1;}else{left=mid+1;}}else{// 右侧有序if(target>nums[mid]&&target<=nums[right]){left=mid+1;}else{right=mid-1;}}}return-1;}}💡 两种写法逻辑一致,后者更严谨(防整数溢出),但在本题约束下均可。
五、代码分析
关键判断:nums[l] <= nums[mid]
- 为什么用
<=而不是<?- 当
l == mid(即只剩一个元素)时,nums[l] == nums[mid],此时左半“有序”成立; - 由于数组无重复,
nums[l] == nums[mid]仅在l == mid时发生。
- 当
区间判断的边界处理
左半有序时:
if (nums[l] <= target && target < nums[mid])target < nums[mid]:因为nums[mid]已经判断过 ≠ target;nums[l] <= target:包含等于情况(target 可能在最左)。
右半有序时:
if (nums[mid] < target && target <= nums[r])nums[mid] < target:同理,排除已检查的 mid;target <= nums[r]:包含等于最右的情况。
为什么至少一半有序?
假设旋转点为k,数组为A = B + C,其中B = [k..n-1],C = [0..k-1],且min(B) > max(C)。
任取mid:
- 若
mid在B中 →[l, mid] ⊆ B→ 有序; - 若
mid在C中 →[mid+1, r] ⊆ C→ 有序。
因此,必有一半有序。
六、时间复杂度和空间复杂度分析
| 项目 | 分析 |
|---|---|
| 时间复杂度 | O(log n)每次迭代将搜索空间减半,共log n次 |
| 空间复杂度 | O(1)仅使用常数个变量(l,r,mid等) |
✅ 完全满足题目要求。
七、问题解答(常见疑问)
Q1:如果有重复元素怎么办?
本题保证“值互不相同”,所以无需考虑。
但若允许重复(如 LeetCode 81 题),则nums[l] == nums[mid] == nums[r]时无法判断哪边有序,需退化为线性扫描或特殊处理。
Q2:能否先找旋转点,再二分?
可以!分两步:
- 用二分找最小值位置(即旋转点
k); - 判断
target在前半段还是后半段,再在对应段二分。
但这样需要两次二分,代码更复杂,而本题解法一次完成,更优。
Q3:为什么不用递归?
递归会增加O(log n)的栈空间,不符合O(1)空间要求。迭代更优。
Q4:如何处理空数组?
题目约束nums.length >= 1,但代码开头仍建议加防护(如官方解法所示)。
八、优化思路
优化1:提前终止(微优化)
在循环开始前,可检查target是否在[min, max]范围内:
intmin=Math.min(nums[0],nums[n-1]);intmax=Math.max(nums[0],nums[n-1]);if(target<min||target>max)return-1;但旋转数组的min和max不一定是nums[0]和nums[n-1],此优化错误!
正确做法:无法提前判断,必须搜索。
优化2:位运算加速(不推荐)
mid = (l + r) >>> 1可防溢出,但本题n ≤ 5000,无需。
优化3:模板化(工程实践)
将“判断哪边有序”的逻辑封装,提高可读性:
booleanleftSorted=nums[left]<=nums[mid];if(leftSorted){// ...}else{// ...}九、数据结构与算法基础知识点回顾
1. 二分查找的适用条件
- 经典二分:全局有序;
- 变种二分:局部有序 + 可判断搜索方向(如本题、山脉数组)。
2. 旋转数组的性质
- 由两个升序子数组拼接;
- 存在一个“断点”,使得
nums[i] > nums[i+1]; - 最小值在断点后(即第二段首元素)。
3. 循环不变式
在每次迭代中:
target要么在[l, r]中,要么不存在;- 通过有序性,能正确缩小搜索范围。
4. 边界测试用例
必须测试以下情况:
- 未旋转(
k=0):[1,2,3,4] - 旋转点在末尾(
k=n-1):[2,1] - 单元素数组;
- target 在第一段/第二段/不存在。
十、面试官提问环节(模拟对话)
面试官:你的判断条件是
nums[l] <= nums[mid],如果数组有重复元素,还能这么写吗?
✅回答:不能。例如nums = [1,1,1,0,1],当l=0, mid=2时,nums[l] == nums[mid] == 1,但左半[1,1,1]虽有序,右半[0,1]也有序,无法确定target=0在哪边。此时需特殊处理,如跳过重复元素或退化为线性。
面试官:能否用三分查找?
✅回答:理论上可以,但二分已是最优(每次排除一半)。三分会增加比较次数,效率更低。
面试官:如果要求返回所有 target 的位置(假设允许重复),怎么办?
✅回答:先用本题方法找到任意一个位置,再向左右线性扩展。但若要求O(log n),则需结合“查找边界”技巧(类似 LeetCode 34 题),但前提是能判断有序段。
面试官:你的算法在最坏情况下还是 O(log n) 吗?
✅回答:是的。因为每次迭代都将搜索空间至少减半,无论走哪条分支,总步数不超过log n。
十一、这道算法题在实际开发中的应用
1. 分布式系统中的日志轮转
- 日志文件按时间滚动,旧日志归档;
- 归档后的日志索引可能形成“旋转有序”结构;
- 快速定位某时间点的日志位置。
2. 缓存淘汰策略(如 Clock 算法)
- 缓存项按访问顺序组织,形成环形结构;
- 查找特定 key 时,可视为在旋转数组中搜索。
3. 版本控制系统(如 Git)
- 提交历史按时间排序,但分支合并后形成非线性历史;
- 某些操作(如 bisect)需在“部分有序”的提交图中查找。
4. 嵌入式系统中的环形缓冲区
- 数据按时间写入环形 buffer;
- 查找特定值时,buffer 内容相当于旋转数组。
5. 数据库分区表
- 数据按范围分区,但分区顺序可能因维护操作打乱;
- 查询优化器需在“局部有序”的分区列表中快速定位。
十二、相关题目推荐
| 题号 | 题目 | 难度 | 关联点 |
|---|---|---|---|
| 33. 搜索旋转排序数组 | 中等 | 本题 | |
| 81. 搜索旋转排序数组 II | 中等 | 允许重复元素 | |
| 153. 寻找旋转排序数组中的最小值 | 中等 | 找旋转点 | |
| 154. 寻找旋转排序数组中的最小值 II | 困难 | 允许重复 | |
| 162. 寻找峰值 | 中等 | 局部有序 | |
| 74. 搜索二维矩阵 | 中等 | 二维二分 |
🔔 学习路径建议:153 → 33 → 81 → 154
十三、总结与延伸
核心思想总结
- 局部有序也可二分:只要能判断哪部分有序,并利用有序性排除搜索空间;
- 旋转数组的特性:任意分割,必有一半有序;
- 边界处理要严谨:特别是等于号的使用;
- 一次二分优于多次:避免不必要的复杂度。
延伸思考
如果旋转方向是向右?
→ 结果相同,数组结构不变。如果数组很大,无法全加载到内存?
→ 可结合外部存储,每次只读取所需段,仍保持O(log n)次 I/O。能否推广到 K 次旋转?
→ 多次旋转等价于一次旋转(模 n),所以无需改变算法。
工程建议
- 在生产代码中,优先考虑清晰性和鲁棒性;
- 在面试中,强调“为什么至少一半有序”这一关键洞察;
- 始终写测试用例:覆盖旋转点在各位置的情况。
结语
“搜索旋转排序数组”是一道极具启发性的算法题。它打破了“二分查找只能用于全局有序数组”的思维定式,展示了如何在部分有序的结构中,通过逻辑推理恢复二分的能力。
正如计算机科学家 Donald Knuth 所言:“过早优化是万恶之源,但不过早思考算法复杂度更是灾难。” 本题正是对这一思想的完美诠释——在看似无序的数据中,发现隐藏的秩序,并利用它实现高效查找。
✨练习建议:
- 手写代码,确保理解每行逻辑;
- 尝试修改为找最小值(LeetCode 153);
- 思考如何处理重复元素(LeetCode 81)。
掌握这道题,你就掌握了在“混沌中寻找秩序”的算法智慧。