当一个有序数组被打乱成两段,如何用 O(log n) 找到目标值?
问题引入
今天我们来看一道 LeetCode 上的经典二分查找变种题 —— 33. 搜索旋转排序数组。
题目背景:原本一个升序排列、元素互不相同的数组,在某个未知位置被向左旋转(也可以理解为循环右移)了。例如 [0,1,2,4,5,6,7] 在下标 3 处旋转后变成 [4,5,6,7,0,1,2]。给你旋转后的数组和一个目标值 target,要你找到 target 的下标,找不到返回 -1。并且要求时间复杂度 O(log n) 。
这道题难就难在数组已经不是完全有序的了,但它由两个分别有序的片段拼接而成。我们仍然可以用二分查找来解决,只是需要多做一些判断——每次二分后,我们要能判断出哪一半是有序的,然后根据有序部分的信息来决定目标值可能在哪一半。
下面我会从最简单的情况开始,一步步推导出解决方案,保证你看完能自己写出正确的代码。
旋转数组长什么样?
先明确“向左旋转”的含义。假设原数组为 A = [a0, a1, ..., a_{n-1}],且 a0 < a1 < ... < a_{n-1}(严格递增,互不相同)。选择某个下标 k(0 ≤ k < n),新的数组变为:
text
B = [a_k, a_{k+1}, ..., a_{n-1}, a_0, a_1, ..., a_{k-1}]
换句话说,就是把原数组的前 k 个元素挪到了末尾。例如:
- 原数组:
[0,1,2,4,5,6,7] - k = 3 →
[4,5,6,7,0,1,2] - k = 0 →
[0,1,2,4,5,6,7](没有旋转) - k = 7 →
[7,0,1,2,4,5,6](最后一个元素变成第一个)
观察发现,旋转后的数组有一个非常重要的特征:从中间切一刀,至少有一半是有序的(升序) 。比如 [4,5,6,7,0,1,2],从中间切开,左半部分 [4,5,6,7] 是有序的,右半部分 [0,1,2] 也是有序的。再比如 [6,7,0,1,2,4,5],左半 [6,7,0] 不是有序的,但右半 [1,2,4,5] 是有序的。实际上,由于旋转点只有一个,所以任何 mid 划分后,左右两半中必然有一半是完全升序的。
这个性质就是我们二分查找的突破口。
思路:二分 + 判断有序部分
我们依然使用 left 和 right 指针,每次取中间位置 mid。对于当前区间 [left, right],我们需要知道 target 是否可能在这个区间内,然后决定移动 left 还是 right。
由于数组整体不是有序的,我们不能简单地用 nums[mid] 和 target 比较后直接决定方向。我们需要先判断哪一半是有序的,然后利用有序部分的端点值来判断 target 是否落在该有序范围内。
具体做法:
-
计算
mid = (left + right) // 2。 -
如果
nums[mid] == target,直接返回mid。 -
判断左半部分
[left, mid]是否有序。如何判断?比较nums[left]和nums[mid]:- 如果
nums[left] <= nums[mid],说明[left, mid]是升序的(注意:因为元素互不相同,等于情况只可能发生在left == mid,也是有序的)。 - 否则,说明左半部分不是有序的,那么右半部分
[mid, right]一定是升序的(画个图就能明白)。
- 如果
-
情况一:左半部分有序
- 此时我们知道
nums[left]到nums[mid]是递增的。 - 如果
target落在[nums[left], nums[mid])这个区间内(注意左闭右开,因为nums[mid]已经单独判断过是否等于target),那么target一定在左半部分,我们移动right = mid - 1。 - 否则,
target在右半部分,移动left = mid + 1。
- 此时我们知道
-
情况二:右半部分有序(即左半部分无序)
- 此时
[mid, right]是升序的。 - 如果
target落在(nums[mid], nums[right]]区间内,那么target在右半部分,移动left = mid + 1。 - 否则,
target在左半部分,移动right = mid - 1。
- 此时
-
重复直到
left > right,如果没找到返回 -1。
画图理解
为了更直观,我们用例子 nums = [4,5,6,7,0,1,2],target = 0。
- 初始 left=0, right=6, mid=3 → nums[3]=7。
- 判断左半 [4,5,6,7] 有序(4 ≤ 7 成立)。
- target=0 是否在 [4,7) 内?否,所以去右半:left=4。
- left=4,right=6,mid=5 → nums[5]=1。
- 此时 left=4, mid=5, nums[4]=0, nums[5]=1。判断左半 [4,5] 即 [0,1] 有序吗?0 ≤ 1 成立,有序。
- target=0 是否在 [0,1) 内?是(0 ≤ 0 < 1),所以去左半:right=4。
- left=4,right=4,mid=4 → nums[4]=0 等于 target,返回 4 ✅。
再试一个找不到的例子:target = 3。过程类似,最终 left > right,返回 -1。
代码实现
按照上述逻辑,我们可以写出如下代码(JavaScript 版本):
javascript
var search = function(nums, target) {
let left = 0;
let right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
return mid;
}
// 判断左半部分是否有序
if (nums[left] <= nums[mid]) {
// 左半部分有序
if (nums[left] <= target && target < nums[mid]) {
right = mid - 1; // target 在左侧有序部分
} else {
left = mid + 1; // target 在右侧
}
} else {
// 右半部分有序(因为左半无序,右半必然有序)
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1; // target 在右侧有序部分
} else {
right = mid - 1; // target 在左侧
}
}
}
return -1;
};
注意边界条件:
- 当
nums[left] <= nums[mid]时,左半有序。但nums[mid]已经排除了相等,所以判断target是否在[nums[left], nums[mid])范围内时,用的是< nums[mid]。 - 当右半有序时,判断
target是否在(nums[mid], nums[right]]范围内,用的是> nums[mid]。 - 端点相等的情况:例如
left == mid时,nums[left] <= nums[mid]成立,左半只有一个元素,有序。判断target是否在[nums[left], nums[mid])—— 区间左闭右开,右边是nums[mid]本身,但因为已经检查过nums[mid] != target,所以实际就是判断target == nums[left]。如果等于,会在下一次循环中被mid抓到。逻辑正确。
边界情况讨论
1. 数组长度为空或只有一个元素
- 空数组:直接不进入循环,返回 -1。
- 单元素数组:假设
[1],target=1:mid=0, nums[0]==target,返回 0。target=0:mid=0,不相等,left=0, right=0,nums[left] <= nums[mid]成立(1<=1),进入左半有序分支,target=0是否在[1,1)?否,所以left=mid+1=1,循环结束返回 -1。正确。
2. 没有旋转(k=0)
例如 nums = [1,2,3,4,5],target=3。整个过程和普通二分一样,因为每次左半都是有序的,并且 target 都会正确落在有序区间内。代码也能正常工作,不会出错。
3. 完全旋转(k = n-1,即第一个元素是原最大值)
例如 nums = [5,1,2,3,4]。mid=2 → nums[2]=2,左半 [5,1,2] 无序(因为 5 > 1),所以进入右半有序分支。右半 [2,3,4] 有序。判断 target 是否在 (2,4] 范围内,然后继续。依然能正确找到。
复杂度分析
- 时间复杂度:O(log n)。每次循环都将搜索范围缩小一半,和标准二分一样。
- 空间复杂度:O(1)。只使用了几个指针变量,没有额外数组。
易错点提醒
- 比较运算符的边界:在判断
target是否在有序区间内时,注意左右端点的开闭。因为nums[mid]已经不等于target,所以使用<或>而不是<=或>=,避免逻辑混乱。 nums[left] <= nums[mid]中的等于号:当left == mid时,等于号成立,左半只有一个元素,我们认为它也是有序的。如果不加等号,会错误地进入 else 分支,导致问题。- 题目保证元素互不相同:如果没有这个条件,
nums[left] == nums[mid]的情况处理会更复杂(可能重复元素导致无法判断哪边有序)。但本题给定互不相同,所以放心用上面的逻辑。
与标准二分的对比
| 特性 | 标准二分查找 | 旋转数组二分 |
|---|---|---|
| 数组状态 | 完全有序 | 分成两段有序 |
| 判断依据 | 直接比较 nums[mid] 与 target | 先判断哪一半有序,再根据有序部分的端点决定 |
| 移动方向 | 单一比较结果 | 分支条件更复杂,但依然是每次舍去一半 |
| 时间复杂度 | O(log n) | O(log n) |
完整的测试用例
javascript
// 测试
console.log(search([4,5,6,7,0,1,2], 0)); // 4
console.log(search([4,5,6,7,0,1,2], 3)); // -1
console.log(search([1], 0)); // -1
console.log(search([1,3], 3)); // 1
console.log(search([3,1], 1)); // 1
console.log(search([5,1,2,3,4], 1)); // 1
console.log(search([1,2,3,4,5], 3)); // 2
举一反三
掌握了这种“判断有序部分”的思路,你还可以解决类似的题目:
- 81. 搜索旋转排序数组 II(允许重复元素)——需要处理重复导致无法判断有序的特殊情况。
- 153. 寻找旋转排序数组中的最小值——同样利用二分找到旋转点。
- 154. 寻找旋转排序数组中的最小值 II(含重复元素)。
核心都是利用旋转数组“一半有序”的性质,在二分过程中根据有序部分的边界信息来决定搜索方向。
总结
今天我们学习了如何在旋转排序数组中使用二分查找。关键步骤:
- 计算中点 mid。
- 判断左半还是右半是有序的。
- 根据有序部分的端点确定 target 是否在其中,从而移动 left 或 right。
- 重复直到找到或区间为空。
这道题非常经典,面试中也常出现。建议大家多画图、手动模拟几次,彻底理解各个分支的含义。当你能熟练写出这段代码时,你对二分查找的理解就会上升一个台阶。
如果觉得这篇文章对你有帮助,不妨点个赞或者收藏一下。我们下期见!