33. 搜索旋转排序数组 | 难度:中等
1. 题意理解(用样例说话)
先看题目给的例子:
nums = [4,5,6,7,0,1,2], target = 0
这个数组原本是 [0,1,2,4,5,6,7],在下标 k=4 处向左旋转后变成上面的样子。
关键观察 :旋转后的数组虽然整体不再有序,但它有一个重要性质------把数组从任意位置切成两半,至少有一半是有序的。
为什么?因为旋转只是把数组"掰"了一下。拿 [4,5,6,7,0,1,2] 来说:
7
6 \
5 0
4 1
2
切一刀 →
左半 [4,5,6] 有序 ✓ 右半 [7,0,1,2] 无序 ✗
左半 [4,5,6,7] 有序 ✓ 右半 [0,1,2] 有序 ✓
这个性质就是我们用二分查找的入口。
样例推演
用表格跟踪 target = 0 的查找过程:
| 步骤 | l | r | 当前窗口 | m | nums[m] | s=nums[l] | e=nums[r-1] | nums[m] >= s? | 在哪一半 | 目标区间判断 | 行动 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 0 | 7 | [4,5,6,7,0,1,2] | 3 | 7 | 4 | 2 | ✅(左有序) | target=0 不在 [4,7) | 搜右边:l=4 | |
| 2 | 4 | 7 | [0,1,2] | 5 | 1 | 0 | 2 | ✅(左有序) | target=0 在 [0,1) | 搜左边:r=5 | |
| 3 | 4 | 5 | [0] | 4 | 0 | 0 | 0 | ✅(左有序) | nums[m]==target | 返回 4 |
输出:4(target 0 在原数组的位置)
如果 target 不存在,比如 target = 3:
| 步骤 | l | r | m | nums[m] | s | e | 判断 | 行动 |
|---|---|---|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 7 | 4 | 2 | 左有序, 3∉[4,7) | l=4 |
| 2 | 4 | 7 | 5 | 1 | 0 | 2 | 左有序, 3∉[0,1) | l=6 |
| 3 | 6 | 7 | 6 | 2 | 2 | 2 | 左有序, 3∉[2,2) | l=7 |
| 4 | 7 | 7 | --- | --- | --- | --- | l>=r 退出 | 返回 -1 |
2. 解法思路(核心)
直觉:普通二分为什么不行?
普通二分查找依赖一个前提:数组全局有序 。nums[m] > target 时,我们知道答案一定在左半边。
但在旋转数组中,nums[m] > target 不能告诉我们方向,因为数组是"弯"的。
关键:利用"一半有序"
虽然全局无序,但我们能保证:以 m 为界,左边和右边至少有一半是从小到大有序的。
怎么判断哪一半有序?只需要比较 nums[m] 和窗口左边界 nums[l]:
- 如果
nums[m] >= nums[l]:左半[l, m]是递增的。因为旋转点一定不在这一段。 - 如果
nums[m] < nums[l]:说明跨越了旋转点,左半不是递增的,那右半[m, r-1]一定是递增的。
一旦确定了哪一半有序,就做两件事:
-
检查 target 是否落在这个有序半区的范围内。
-
如果在,就在这个半区继续搜;否则去另一半。
┌─────────────────────────────────┐ │ nums[m] >= nums[l] ? │ └──────────┬──────────────────────┘ │ ┌───────────┴───────────┐ ▼ ▼ 左半有序 右半有序 [l, m] 递增 [m, r-1] 递增 │ │ target 在 target 在 [nums[l], nums[m]) ? (nums[m], nums[r-1]] ? │ │ ┌─────┴─────┐ ┌─────┴─────┐ ▼ ▼ ▼ ▼ 是 否 是 否r = m l = m+1 l = m+1 r = m
(去左边) (去右边) (去右边) (去左边)
为什么用 >= 而不是 >?
考虑只剩一个元素的情况:l == m,此时 nums[m] == nums[l]。
如果写成 nums[m] > nums[l](严格大于),这个条件为 false,程序会误判为"右半有序",然后去检查 (nums[m], nums[r-1]]------但此时 m == r-1,右半区间为空,逻辑就乱了。
用 >= 保证单个元素也被正确处理为"左半有序"。
为什么每轮都要重新算 s 和 e?
s = nums[l] 和 e = nums[r-1] 是当前搜索窗口的左右边界值。窗口每轮都在缩小,边界值当然也跟着变。把它们放在循环体内更新是必要的。
3. 代码实现
参考你提供的代码,我们可以这样实现(逐行注释):
cpp
class Solution {
public:
int search(vector<int>& nums, int target) {
// 使用半开区间 [l, r),所以 r 初始化为 nums.size()
int l = 0, r = nums.size();
// s 和 e:当前搜索窗口的左右边界值
int s = nums[0], e = nums[r - 1];
int ret = -1;
while (l < r) { // 半开区间:l == r 时区间为空
int m = (l + r) / 2;
// 每轮更新窗口边界值------窗口缩小了,边界也跟着变
s = nums[l];
e = nums[r - 1];
if (nums[m] == target) {
ret = m;
break;
}
// 情况1:m 在左半有序区(nums[m] >= 窗口左边界值)
else if (nums[m] >= s) {
// target 落在 [s, nums[m]) 这个有序区间内吗?
// 注意:这里用 nums[m] > target 等价于 target < nums[m],
// 因为前面已经排除了 nums[m] == target 的情况
if (s <= target && nums[m] > target) {
r = m; // 在左半,收缩右边界
} else {
l = m + 1; // 不在左半,去右边
}
}
// 情况2:m 在右半有序区(跨越了旋转点,右半才是递增的)
else {
// target 落在 (nums[m], e] 这个有序区间内吗?
if (nums[m] < target && target <= e) {
l = m + 1; // 在右半,收缩左边界
} else {
r = m; // 不在右半,去左边
}
}
}
return ret;
}
};
时间复杂度:O(log n),每次迭代将搜索范围减半。
空间复杂度:O(1),只用了常数个变量。
4. 易错点(这道题的坑真的不少)
坑1:>= 还是 > ?(判断哪半有序时)
用 nums[m] > nums[l] 看似也可以------只要 l != m。但当区间缩到只有一个元素(l == m)时,nums[m] > nums[l] 为 false,程序会走进 "else" 分支,认为右半有序。但此时右半是空的,后续的区间判断就会完全错乱。
正确做法 :始终用 nums[m] >= nums[l],让单元素区间也被正确处理。
坑2:target 区间判断的边界开闭
在 nums[m] >= s 分支中,我们检查 target 是否在 [s, nums[m]) 范围内------左闭右开。因为如果 target == nums[m],前面已经 return 了,所以用 < target 或 > target 都可以。写成:
cpp
if (s <= target && nums[m] > target) // target ∈ [s, nums[m])
这里 s <= target 是 <= 而不是 <,因为 target 可能正好等于窗口左边界值。写成 < 会漏掉这个情况,导致无限循环或错误结果。
在 else 分支中同理,检查的是 (nums[m], e]------左开右闭:
cpp
if (nums[m] < target && target <= e) // target ∈ (nums[m], e]
target <= e 用 <= 是正确的,因为 target 可能正好等于窗口右边界值。
坑3:l = m + 1 还是 l = m?
半开区间 [l, r) 下:
- 要排除 m 去右边 →
l = m + 1(因为 nums[m] 已经检查过了) - 要排除 m 去左边 →
r = m(因为 r 是开边界,自然排除 m)
写成 l = m 会导致死循环:当 r = l + 1 时,m = l,l = m 意味着 l 不移动。
坑4:初始值 r = nums.size() vs r = nums.size() - 1
你的代码用了半开区间,所以 r = nums.size(),搭配 while (l < r)。
如果用闭区间写法 r = nums.size() - 1,则搭配 while (l <= r),且停止条件、边界移动方式都要相应调整。两种写法都对,但必须配套。混用是最常见的 bug 来源。
坑5:忘记每轮更新 s 和 e
有人会这样写:
cpp
int s = nums[0], e = nums.back(); // 只在循环外初始化一次
while (l < r) {
// s 和 e 始终是初始数组的首尾值,不会随窗口缩小而更新!
if (nums[m] >= s) { ... }
}
这是错的。随着 l 和 r 移动,窗口边界变了,s 和 e 必须反映当前窗口 的边界值,所以每轮都要 s = nums[l]; e = nums[r-1];。
5. 相关题目
| 题号 | 题目 | 关联点 |
|---|---|---|
| 81 | 搜索旋转排序数组 II | 允许重复元素,需要额外处理 nums[l] == nums[m] == nums[r-1] 的情况 |
| 153 | 寻找旋转排序数组中的最小值 | 同样利用"一半有序"性质,但目标从查找特定值变成了找最小值的拐点 |
| 154 | 寻找旋转排序数组中的最小值 II | 153 的带重复元素版本 |
6. 总结
这道题的核心收获:
-
"全局不有序 ≠ 二分不能用" 。只要每次能判断目标在哪一半,二分就成立。旋转数组的特殊之处在于虽然整体有序性被破坏,但每次切分后必有一半是有序的------这是本题能用 O(log n) 的根本原因。
-
边界条件的精度决定成败 。
>=vs>、<=vs<、开区间 vs 闭区间------二分查找的每一个等号都不是随意的。写完后用一个元素的数组[1] target=1、两个元素的数组[3,1] target=1这类极端 case 验证一遍,大部分边界 bug 会立刻暴露。 -
可泛化的原则 :当需要在"部分有序"的结构中搜索时,不要试图还原整个有序结构------找到那个局部不变量(本题中是"切分后必有一半有序"),然后围绕它设计判断逻辑。