搜索旋转排序数组:二分查找的奇妙变形

0 阅读8分钟

当一个有序数组被打乱成两段,如何用 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 是否落在该有序范围内。

具体做法:

  1. 计算 mid = (left + right) // 2

  2. 如果 nums[mid] == target,直接返回 mid

  3. 判断左半部分 [left, mid] 是否有序。如何判断?比较 nums[left] 和 nums[mid]

    • 如果 nums[left] <= nums[mid],说明 [left, mid] 是升序的(注意:因为元素互不相同,等于情况只可能发生在 left == mid,也是有序的)。
    • 否则,说明左半部分不是有序的,那么右半部分 [mid, right] 一定是升序的(画个图就能明白)。
  4. 情况一:左半部分有序

    • 此时我们知道 nums[left] 到 nums[mid] 是递增的。
    • 如果 target 落在 [nums[left], nums[mid]) 这个区间内(注意左闭右开,因为 nums[mid] 已经单独判断过是否等于 target),那么 target 一定在左半部分,我们移动 right = mid - 1
    • 否则,target 在右半部分,移动 left = mid + 1
  5. 情况二:右半部分有序(即左半部分无序)

    • 此时 [mid, right] 是升序的。
    • 如果 target 落在 (nums[mid], nums[right]] 区间内,那么 target 在右半部分,移动 left = mid + 1
    • 否则,target 在左半部分,移动 right = mid - 1
  6. 重复直到 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=0nums[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)。只使用了几个指针变量,没有额外数组。

易错点提醒

  1. 比较运算符的边界:在判断 target 是否在有序区间内时,注意左右端点的开闭。因为 nums[mid] 已经不等于 target,所以使用 < 或 > 而不是 <= 或 >=,避免逻辑混乱。
  2. nums[left] <= nums[mid] 中的等于号:当 left == mid 时,等于号成立,左半只有一个元素,我们认为它也是有序的。如果不加等号,会错误地进入 else 分支,导致问题。
  3. 题目保证元素互不相同:如果没有这个条件,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(含重复元素)。

核心都是利用旋转数组“一半有序”的性质,在二分过程中根据有序部分的边界信息来决定搜索方向。


总结

今天我们学习了如何在旋转排序数组中使用二分查找。关键步骤:

  1. 计算中点 mid。
  2. 判断左半还是右半是有序的。
  3. 根据有序部分的端点确定 target 是否在其中,从而移动 left 或 right。
  4. 重复直到找到或区间为空。

这道题非常经典,面试中也常出现。建议大家多画图、手动模拟几次,彻底理解各个分支的含义。当你能熟练写出这段代码时,你对二分查找的理解就会上升一个台阶。

如果觉得这篇文章对你有帮助,不妨点个赞或者收藏一下。我们下期见!