力扣热题100实战 | 第33期:搜索旋转排序数组------二分查找的变体艺术
-
- 前言
- 一、题目:在旋转数组中找目标值
- 二、第一反应:线性扫描(违反要求)
- 三、核心解法:二分查找(判断有序半区)
-
- [3.1 核心思想](#3.1 核心思想)
- [3.2 算法步骤](#3.2 算法步骤)
- [3.3 图解流程](#3.3 图解流程)
- [3.4 代码实现](#3.4 代码实现)
- [3.5 复杂度分析](#3.5 复杂度分析)
- 四、细节剖析:面试官真正关心的问题
-
- [Q1:为什么条件是 `nums[left] <= nums[mid]` 而不是 `<`?](#Q1:为什么条件是
nums[left] <= nums[mid]而不是<?) - [Q2:如何判断目标值是否在有序半区?为什么有的用 `<` 有的用 `<=`?](#Q2:如何判断目标值是否在有序半区?为什么有的用
<有的用<=?) - Q3:如果数组没有旋转(k=0),算法还能正确工作吗?
- Q4:为什么不需要显式找到旋转点?
- [Q5:如果数组中有重复元素(LeetCode 81),这个算法还适用吗?](#Q5:如果数组中有重复元素(LeetCode 81),这个算法还适用吗?)
- [Q1:为什么条件是 `nums[left] <= nums[mid]` 而不是 `<`?](#Q1:为什么条件是
- 五、面试官追问进阶版
-
- [追问1:如何实现一个方法找到旋转数组中的最小值(LeetCode 153)?](#追问1:如何实现一个方法找到旋转数组中的最小值(LeetCode 153)?)
- 追问2:如果数组旋转了多次(其实等价于一次旋转),是否会影响正确性?
- 追问3:如何用递归实现二分?
- 追问4:如果数组长度非常大(10⁷),这个二分法仍然高效吗?
- 六、实际开发:这道题到底有什么用?
- 七、总结:从一道题到一类题
- 附录:思考题
当有序数组被旋转,二分查找还能用吗?答案是肯定的,核心在于判断哪一半是单调的。这道题教会我们:在看似混乱的数据中,只要找到一个有序的片段,就能继续用二分缩小范围。
前言
你好,我是@礼拜天没时间。
上一期我们攻克了"最长有效括号"(第32题),掌握了动态规划和栈在括号匹配中的应用。这一期,我们来解一道二分查找的经典变体------搜索旋转排序数组(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,如果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
关键点解读
-
唯一性 :数组中的值互不相同,这简化了问题。进阶版(第81题)允许重复元素,会更复杂。
-
旋转定义:本质就是把原有序数组的前面一部分(长度为k)移到末尾,形成"两段有序"的结构。
-
时间复杂度要求 :O(log n),这直接暗示我们要用二分查找。
-
寻找旋转点:不一定需要显式找到旋转点,可以在二分过程中动态判断哪一半是有序的。
二、第一反应:线性扫描(违反要求)
当我第一次看到这道题,我的第一反应是:直接遍历一遍,找到目标值返回下标。
java
class Solution {
public int search(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) {
if (nums[i] == target) return i;
}
return -1;
}
}
- 时间复杂度:O(n) ------ 不符合题目要求
- 空间复杂度:O(1)
虽然能过测试,但题目明确要求 O(log n),必须用二分。
三、核心解法:二分查找(判断有序半区)
3.1 核心思想
因为数组原本有序,只是被旋转了一次,所以它有一个重要的性质:对于任意中间点 mid,数组的左半部分 [left, mid] 和右半部分 [mid+1, right] 至少有一个是严格递增的。
我们可以通过比较 nums[left] 和 nums[mid] 来判断哪一边是有序的:
- 如果
nums[left] <= nums[mid],说明左半部分 [left, mid] 是递增的(没有旋转点落在这一区间)。 - 否则,右半部分 [mid+1, right] 是递增的。
确定了有序半区之后,我们就可以判断目标值是否在这个有序半区内:
- 如果在,则继续在这个半区内二分。
- 如果不在,则目标在另一半(无序半区),继续二分。
3.2 算法步骤
- 初始化
left = 0,right = nums.length - 1 - 当
left <= right时循环:- 计算
mid = left + (right - left) / 2 - 如果
nums[mid] == target,直接返回mid - 判断左半部分是否有序:
if (nums[left] <= nums[mid])- 若左半有序,检查
target是否在[nums[left], nums[mid])范围内:- 如果是,则
right = mid - 1 - 否则,
left = mid + 1
- 如果是,则
- 若左半有序,检查
- 否则,右半部分有序:
- 检查
target是否在(nums[mid], nums[right]]范围内:- 如果是,则
left = mid + 1 - 否则,
right = mid - 1
- 如果是,则
- 检查
- 计算
- 循环结束返回 -1
3.3 图解流程
以 nums = [4,5,6,7,0,1,2], target = 0 为例:
第1步 :left=0, right=6, mid=3
nums = [4,5,6,7,0,1,2]
↑ ↑ ↑
left mid right
nums[left]=4 <= nums[mid]=7→ 左半 [4,5,6,7] 有序。- target=0 不在 [4,7] 内 → 去右半
left=mid+1=4
第2步 :left=4, right=6, mid=5
nums = [4,5,6,7,0,1,2]
↑
left,mid?
准确下标:left=4(0), right=6(2), mid=5(1)
nums[left]=0 <= nums[mid]=1→ 左半 [0,1] 有序。- target=0 在 [0,1] 内 →
right=mid-1=4
第3步 :left=4, right=4, mid=4
nums[mid]=0 == target→ 返回4 ✅
3.4 代码实现
java
class Solution {
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
// 判断左半部分是否有序
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;
}
}
3.5 复杂度分析
- 时间复杂度:O(log n) ------ 每次循环将区间缩小一半
- 空间复杂度:O(1) ------ 只用了常数变量
四、细节剖析:面试官真正关心的问题
Q1:为什么条件是 nums[left] <= nums[mid] 而不是 <?
答案 :当 left == mid 时(即区间内只有1或2个元素),nums[left] <= nums[mid] 成立,此时左半部分也是有序的(单个元素自然有序)。如果用 <,可能会在 left == mid 时误判,导致逻辑错误。
Q2:如何判断目标值是否在有序半区?为什么有的用 < 有的用 <=?
答案 :因为我们已经检查过 nums[mid] == target,所以:
- 对于左半部分有序,范围是
[nums[left], nums[mid]),左闭右开,因为mid已经排除。 - 对于右半部分有序,范围是
(nums[mid], nums[right]],左开右闭。
这样能避免重复判断mid位置。
Q3:如果数组没有旋转(k=0),算法还能正确工作吗?
答案 :能。此时 nums[left] <= nums[mid] 始终成立,算法会一直走左半有序分支,等同于在完全有序数组上进行普通二分查找。
Q4:为什么不需要显式找到旋转点?
答案 :因为旋转信息隐含在比较 nums[left] 和 nums[mid] 的结果中。我们不需要提前知道旋转点位置,只需要在每次二分时动态判断哪一半有序,然后根据 target 决定下一步搜索区间。
Q5:如果数组中有重复元素(LeetCode 81),这个算法还适用吗?
答案 :不能直接适用,因为当 nums[left] == nums[mid] 时,无法判断哪一半有序,需要退化到线性扫描或特殊处理。LeetCode 81 提供了进阶版本。
五、面试官追问进阶版
追问1:如何实现一个方法找到旋转数组中的最小值(LeetCode 153)?
思路 :同样用二分,但比较的是 nums[mid] 和 nums[right]。如果 nums[mid] > nums[right],说明最小值在右半;否则在左半(包含 mid)。类似地,也可以比较 nums[left] 和 nums[mid]。
java
public int findMin(int[] nums) {
int left = 0, right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) {
left = mid + 1;
} else {
right = mid;
}
}
return nums[left];
}
追问2:如果数组旋转了多次(其实等价于一次旋转),是否会影响正确性?
答案:不会。任意次旋转等价于一次旋转(因为旋转是循环移动),我们的算法依然有效。
追问3:如何用递归实现二分?
思路:递归分解子问题,本质上与迭代相同,但递归深度 O(log n) 会有额外空间开销。
追问4:如果数组长度非常大(10⁷),这个二分法仍然高效吗?
答案:是的,二分查找的对数复杂度在数据量极大时优势非常明显。10⁷ 的数据量只需约 24 次比较即可完成。
六、实际开发:这道题到底有什么用?
很多读者会问:"旋转数组中查找,实际工作中哪用得到?"
其实它的思想无处不在:
场景1:日志索引查询
日志系统常常按时间戳排序存储,但可能因为系统故障导致部分日志被移动到其他位置(相当于旋转)。二分查找的思想能快速定位特定时间点的日志。
场景2:循环队列中的查找
循环队列在物理上是线性的,但逻辑上是环形的。在环形缓冲中查找元素,可以转化为旋转数组的查找。
场景3:数据库索引中的断裂页
某些数据库索引中,数据页可能因为整理导致顺序被打乱但依然是部分有序,二分查找变体可以用于在这些段中快速定位。
场景4:游戏开发中的物品栏排序
游戏中的背包物品可能按某种顺序排列,但用户可以对物品进行旋转排序(如把前面的物品移到后面),查找特定物品时可以用类似思想。
场景5:算法面试题
这道题是二分查找变体中最经典的考题,考察对二分思想的深刻理解。
七、总结:从一道题到一类题
回顾一下,我们从搜索旋转排序数组学到了什么:
| 维度 | 收获 |
|---|---|
| 算法思维 | 线性扫描 → 二分查找,理解"部分有序"性质如何被利用 |
| 代码技巧 | 动态判断有序半区、区间端点比较的细节 |
| 复杂度分析 | O(log n) 时间、O(1) 空间,最优解 |
| 面试要点 | 为什么要判断有序半区?条件为什么是 <=?如何处理边界? |
| 工程关联 | 日志索引、环形缓冲、数据库索引、背包管理 |
力扣热题100的第三十三题,不是为了难住你,而是为了告诉你:二分查找的舞台远不止完全有序的数组。学会识别问题中的"部分有序",你就能让二分查找的光芒在不完美中继续闪耀。
下一期预告:《在排序数组中查找元素的第一个和最后一个位置》------二分查找的两次运用
附录:思考题
看完这篇文章,你可以试着回答:
- 如果数组中允许重复元素(LeetCode 81),上述算法需要怎么调整?
- 如何用这个思想解决"旋转数组中的峰值查找"问题?
- 你能用这道题的思路,去解 LeetCode 153(寻找旋转排序数组中的最小值)和 LeetCode 154(寻找旋转排序数组中的最小值 II)吗?
欢迎在评论区留下你的思考!